mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Implement centralized logging with RMABLogger
Replaces scattered console statements with a unified RMABLogger across backend API routes and services. Adds LOG_LEVEL-based filtering, job-aware database persistence, and context-based logging. Updates documentation to describe the new logging system and usage patterns. Also documents qBittorrent CSRF header fix
This commit is contained in:
+49
-45
@@ -9,6 +9,9 @@ import { getConfigService } from '@/lib/services/config.service';
|
||||
import { AudibleService } from '@/lib/integrations/audible.service';
|
||||
import { getPlexService } from '@/lib/integrations/plex.service';
|
||||
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('BookDate');
|
||||
|
||||
export interface LibraryBook {
|
||||
title: string;
|
||||
@@ -57,7 +60,7 @@ async function enrichWithUserRatings(
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
console.warn('[BookDate] User not found');
|
||||
logger.warn('User not found');
|
||||
return cachedBooks.map(book => ({
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
@@ -69,7 +72,7 @@ async function enrichWithUserRatings(
|
||||
// Local admin users: Use cached ratings (from system Plex token)
|
||||
// Local admins authenticate with username/password, not Plex OAuth
|
||||
if (user.plexId.startsWith('local-')) {
|
||||
console.log('[BookDate] User is local admin, using cached ratings (from system Plex token)');
|
||||
logger.info('User is local admin, using cached ratings (from system Plex token)');
|
||||
return cachedBooks.map(book => ({
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
@@ -80,10 +83,10 @@ async function enrichWithUserRatings(
|
||||
|
||||
// Plex-authenticated users (including admins): Fetch library with their token to get personal ratings
|
||||
// Note: /library/sections/{id}/all returns items with the authenticated user's ratings
|
||||
console.log('[BookDate] User is Plex-authenticated, fetching library with user token to get personal ratings');
|
||||
logger.info('User is Plex-authenticated, fetching library with user token to get personal ratings');
|
||||
|
||||
if (!user.authToken) {
|
||||
console.warn('[BookDate] User has no Plex auth token');
|
||||
logger.warn('User has no Plex auth token');
|
||||
return cachedBooks.map(book => ({
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
@@ -97,7 +100,7 @@ async function enrichWithUserRatings(
|
||||
const plexConfig = await configService.getPlexConfig();
|
||||
|
||||
if (!plexConfig.serverUrl || !plexConfig.libraryId) {
|
||||
console.warn('[BookDate] No Plex server URL or library ID configured');
|
||||
logger.warn('No Plex server URL or library ID configured');
|
||||
return cachedBooks.map(book => ({
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
@@ -114,7 +117,7 @@ async function enrichWithUserRatings(
|
||||
} catch (decryptError) {
|
||||
// Token might be stored as plain text (from before encryption or different implementation)
|
||||
// Try using it as-is
|
||||
console.warn('[BookDate] Failed to decrypt user Plex token, trying as plain text');
|
||||
logger.warn('Failed to decrypt user Plex token, trying as plain text');
|
||||
userPlexToken = user.authToken;
|
||||
}
|
||||
|
||||
@@ -126,7 +129,7 @@ async function enrichWithUserRatings(
|
||||
|
||||
// Get server machine ID from stored config (no need to access system token)
|
||||
if (!plexConfig.machineIdentifier) {
|
||||
console.error('[BookDate] Server machine identifier not configured');
|
||||
logger.error('Server machine identifier not configured');
|
||||
return cachedBooks.map(book => ({
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
@@ -142,7 +145,7 @@ async function enrichWithUserRatings(
|
||||
);
|
||||
|
||||
if (!serverAccessToken) {
|
||||
console.warn('[BookDate] Could not get server access token for user (may not have server access)');
|
||||
logger.warn('Could not get server access token for user (may not have server access)');
|
||||
return cachedBooks.map(book => ({
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
@@ -151,7 +154,7 @@ async function enrichWithUserRatings(
|
||||
}));
|
||||
}
|
||||
|
||||
console.log('[BookDate] Successfully obtained server access token for user');
|
||||
logger.info('Successfully obtained server access token for user');
|
||||
|
||||
// Fetch library content with user's SERVER access token to get their personal ratings
|
||||
const userLibrary = await plexService.getLibraryContent(
|
||||
@@ -160,7 +163,7 @@ async function enrichWithUserRatings(
|
||||
plexConfig.libraryId
|
||||
);
|
||||
|
||||
console.log(`[BookDate] Fetched ${userLibrary.length} items from Plex with user's token`);
|
||||
logger.info(`Fetched ${userLibrary.length} items from Plex with user's token`);
|
||||
|
||||
// Create a map of guid/ratingKey -> userRating for quick lookup
|
||||
const ratingsMap = new Map<string, number>();
|
||||
@@ -177,7 +180,7 @@ async function enrichWithUserRatings(
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[BookDate] Found ${ratingsMap.size} rated items for non-admin user`);
|
||||
logger.info(`Found ${ratingsMap.size} rated items for non-admin user`);
|
||||
|
||||
// Enrich cached books with user's ratings from the fetched library
|
||||
return cachedBooks.map(book => {
|
||||
@@ -200,10 +203,10 @@ async function enrichWithUserRatings(
|
||||
|
||||
} catch (fetchError: any) {
|
||||
if (fetchError?.response?.status === 401 || fetchError?.message?.includes('401')) {
|
||||
console.warn('[BookDate] User token unauthorized for library access (shared users may not have direct API access)');
|
||||
console.warn('[BookDate] Falling back to recommendations without user ratings');
|
||||
logger.warn('User token unauthorized for library access (shared users may not have direct API access)');
|
||||
logger.warn('Falling back to recommendations without user ratings');
|
||||
} else {
|
||||
console.error('[BookDate] Failed to fetch library with user token:', fetchError);
|
||||
logger.error('Failed to fetch library with user token', { error: fetchError instanceof Error ? fetchError.message : String(fetchError) });
|
||||
}
|
||||
// Fallback: return books without ratings
|
||||
return cachedBooks.map(book => ({
|
||||
@@ -215,7 +218,7 @@ async function enrichWithUserRatings(
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[BookDate] Error enriching books with user ratings:', error);
|
||||
logger.error('Error enriching books with user ratings', { error: error instanceof Error ? error.message : String(error) });
|
||||
// Fallback: return books without ratings on error
|
||||
return cachedBooks.map(book => ({
|
||||
title: book.title,
|
||||
@@ -242,7 +245,7 @@ export async function getUserLibraryBooks(
|
||||
|
||||
// Early validation: audiobookshelf doesn't support ratings
|
||||
if (backendMode === 'audiobookshelf' && scope === 'rated') {
|
||||
console.warn('[BookDate] Audiobookshelf does not support ratings, falling back to full library');
|
||||
logger.warn('Audiobookshelf does not support ratings, falling back to full library');
|
||||
scope = 'full';
|
||||
}
|
||||
|
||||
@@ -251,7 +254,7 @@ export async function getUserLibraryBooks(
|
||||
if (backendMode === 'audiobookshelf') {
|
||||
const absLibraryId = await configService.get('audiobookshelf.library_id');
|
||||
if (!absLibraryId) {
|
||||
console.warn('[BookDate] No Audiobookshelf library ID configured');
|
||||
logger.warn('No Audiobookshelf library ID configured');
|
||||
return [];
|
||||
}
|
||||
libraryId = absLibraryId;
|
||||
@@ -259,7 +262,7 @@ export async function getUserLibraryBooks(
|
||||
// Plex mode
|
||||
const plexConfig = await configService.getPlexConfig();
|
||||
if (!plexConfig.libraryId) {
|
||||
console.warn('[BookDate] No Plex library ID configured');
|
||||
logger.warn('No Plex library ID configured');
|
||||
return [];
|
||||
}
|
||||
libraryId = plexConfig.libraryId;
|
||||
@@ -327,7 +330,7 @@ export async function getUserLibraryBooks(
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[BookDate] Error fetching library books:', error);
|
||||
logger.error('Error fetching library books', { error: error instanceof Error ? error.message : String(error) });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -390,8 +393,8 @@ export async function getUserRecentSwipes(
|
||||
(a, b) => b.createdAt.getTime() - a.createdAt.getTime()
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[BookDate] Fetched ${allSwipes.length} swipes: ${nonDismissSwipes.length} non-dismiss, ${dismissSwipes.length} dismiss`
|
||||
logger.info(
|
||||
`Fetched ${allSwipes.length} swipes: ${nonDismissSwipes.length} non-dismiss, ${dismissSwipes.length} dismiss`
|
||||
);
|
||||
|
||||
return allSwipes.map((s) => ({
|
||||
@@ -402,7 +405,7 @@ export async function getUserRecentSwipes(
|
||||
}));
|
||||
|
||||
} catch (error) {
|
||||
console.error('[BookDate] Error fetching swipe history:', error);
|
||||
logger.error('Error fetching swipe history', { error: error instanceof Error ? error.message : String(error) });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -424,11 +427,12 @@ export async function buildAIPrompt(
|
||||
|
||||
const swipeHistory = await getUserRecentSwipes(userId, 10);
|
||||
|
||||
console.log('[BookDate] Building AI prompt with context:');
|
||||
console.log(`[BookDate] - Library books: ${libraryBooks.length}`);
|
||||
console.log(`[BookDate] - Swipe history: ${swipeHistory.length}`);
|
||||
console.log(`[BookDate] - Custom prompt: ${config.customPrompt ? 'Yes' : 'No'}`);
|
||||
console.log(`[BookDate] - Library scope: ${config.libraryScope}`);
|
||||
logger.info('Building AI prompt with context:', {
|
||||
libraryBooks: libraryBooks.length,
|
||||
swipeHistory: swipeHistory.length,
|
||||
customPrompt: config.customPrompt ? 'Yes' : 'No',
|
||||
libraryScope: config.libraryScope,
|
||||
});
|
||||
|
||||
const prompt = {
|
||||
task: 'recommend_audiobooks',
|
||||
@@ -466,7 +470,7 @@ export async function buildAIPrompt(
|
||||
};
|
||||
|
||||
const promptString = JSON.stringify(prompt);
|
||||
console.log('[BookDate] Full AI prompt:', promptString);
|
||||
logger.debug('Full AI prompt:', { prompt: promptString });
|
||||
|
||||
return promptString;
|
||||
}
|
||||
@@ -488,7 +492,7 @@ export async function callAI(
|
||||
const encryptionService = getEncryptionService();
|
||||
const apiKey = encryptionService.decrypt(encryptedApiKey);
|
||||
|
||||
console.log(`[BookDate] Calling AI provider: ${provider}, model: ${model}`);
|
||||
logger.info(`Calling AI provider: ${provider}, model: ${model}`);
|
||||
|
||||
if (provider === 'openai') {
|
||||
const systemMessage = 'You are an expert audiobook recommender. Analyze user preferences and suggest audiobooks they will love. Return ONLY valid JSON.';
|
||||
@@ -507,7 +511,7 @@ export async function callAI(
|
||||
],
|
||||
};
|
||||
|
||||
console.log('[BookDate] OpenAI request body:', JSON.stringify(requestBody, null, 2));
|
||||
logger.debug('OpenAI request body:', { requestBody });
|
||||
|
||||
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
@@ -520,13 +524,13 @@ export async function callAI(
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('[BookDate] OpenAI API error:', response.status, errorText);
|
||||
logger.error('OpenAI API error', { status: response.status, error: errorText });
|
||||
throw new Error(`OpenAI API error: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.choices[0].message.content;
|
||||
console.log('[BookDate] OpenAI response:', content);
|
||||
logger.debug('OpenAI response:', { content });
|
||||
return JSON.parse(content);
|
||||
|
||||
} else if (provider === 'claude') {
|
||||
@@ -542,7 +546,7 @@ export async function callAI(
|
||||
],
|
||||
};
|
||||
|
||||
console.log('[BookDate] Claude request body:', JSON.stringify(requestBody, null, 2));
|
||||
logger.debug('Claude request body:', { requestBody });
|
||||
|
||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
@@ -556,13 +560,13 @@ export async function callAI(
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('[BookDate] Claude API error:', response.status, errorText);
|
||||
logger.error('Claude API error', { status: response.status, error: errorText });
|
||||
throw new Error(`Claude API error: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.content[0].text;
|
||||
console.log('[BookDate] Claude raw response:', content);
|
||||
logger.debug('Claude raw response:', { content });
|
||||
|
||||
// Claude sometimes wraps JSON in markdown code blocks, so clean it
|
||||
const cleanedContent = content
|
||||
@@ -570,7 +574,7 @@ export async function callAI(
|
||||
.replace(/\s*```$/i, '')
|
||||
.trim();
|
||||
|
||||
console.log('[BookDate] Claude cleaned response:', cleanedContent);
|
||||
logger.debug('Claude cleaned response:', { cleanedContent });
|
||||
return JSON.parse(cleanedContent);
|
||||
|
||||
} else {
|
||||
@@ -625,7 +629,7 @@ export async function matchToAudnexus(
|
||||
});
|
||||
|
||||
if (cached) {
|
||||
console.log(`[BookDate] Found in cache: "${cached.title}" by ${cached.author}`);
|
||||
logger.info(`Found in cache: "${cached.title}" by ${cached.author}`);
|
||||
return {
|
||||
asin: cached.asin,
|
||||
title: cached.title,
|
||||
@@ -638,29 +642,29 @@ export async function matchToAudnexus(
|
||||
}
|
||||
|
||||
// Step 2: Search Audible.com for the book
|
||||
console.log(`[BookDate] Not in cache, searching Audible for "${title}" by ${author}...`);
|
||||
logger.info(`Not in cache, searching Audible for "${title}" by ${author}...`);
|
||||
const audibleService = new AudibleService();
|
||||
const searchQuery = `${title} ${author}`;
|
||||
const searchResults = await audibleService.search(searchQuery, 1);
|
||||
|
||||
if (!searchResults.results || searchResults.results.length === 0) {
|
||||
console.warn(`[BookDate] No Audible search results for "${title}" by ${author}`);
|
||||
logger.warn(`No Audible search results for "${title}" by ${author}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Take the first result (best match)
|
||||
const firstResult = searchResults.results[0];
|
||||
console.log(`[BookDate] Found on Audible: "${firstResult.title}" (ASIN: ${firstResult.asin})`);
|
||||
logger.info(`Found on Audible: "${firstResult.title}" (ASIN: ${firstResult.asin})`);
|
||||
|
||||
// Step 3: Use ASIN to fetch full details from Audnexus (or Audible as fallback)
|
||||
const details = await audibleService.getAudiobookDetails(firstResult.asin);
|
||||
|
||||
if (!details) {
|
||||
console.warn(`[BookDate] Could not fetch details for ASIN ${firstResult.asin}`);
|
||||
logger.warn(`Could not fetch details for ASIN ${firstResult.asin}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`[BookDate] Successfully matched "${title}" to ASIN ${details.asin}`);
|
||||
logger.info(`Successfully matched "${title}" to ASIN ${details.asin}`);
|
||||
|
||||
return {
|
||||
asin: details.asin,
|
||||
@@ -673,7 +677,7 @@ export async function matchToAudnexus(
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[BookDate] Audnexus matching error for "${title}":`, error);
|
||||
logger.error(`Audnexus matching error for "${title}"`, { error: error instanceof Error ? error.message : String(error) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -703,12 +707,12 @@ export async function isInLibrary(
|
||||
});
|
||||
|
||||
if (match) {
|
||||
console.log(`[BookDate] Book "${title}" by ${author} found in library (matched to: "${match.title}")`);
|
||||
logger.info(`Book "${title}" by ${author} found in library (matched to: "${match.title}")`);
|
||||
}
|
||||
|
||||
return !!match;
|
||||
} catch (error) {
|
||||
console.error(`[BookDate] Error checking library for "${title}":`, error);
|
||||
logger.error(`Error checking library for "${title}"`, { error: error instanceof Error ? error.message : String(error) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
// Module-level logger
|
||||
const logger = RMABLogger.create('Audible');
|
||||
|
||||
export interface AudibleAudiobook {
|
||||
asin: string;
|
||||
@@ -48,14 +52,14 @@ export class AudibleService {
|
||||
*/
|
||||
async getPopularAudiobooks(limit: number = 20): Promise<AudibleAudiobook[]> {
|
||||
try {
|
||||
console.log(`[Audible] Fetching popular audiobooks (limit: ${limit})...`);
|
||||
logger.info(` Fetching popular audiobooks (limit: ${limit})...`);
|
||||
|
||||
const audiobooks: AudibleAudiobook[] = [];
|
||||
let page = 1;
|
||||
const maxPages = Math.ceil(limit / 20); // Audible shows ~20 items per page
|
||||
|
||||
while (audiobooks.length < limit && page <= maxPages) {
|
||||
console.log(`[Audible] Fetching page ${page}/${maxPages}...`);
|
||||
logger.info(` Fetching page ${page}/${maxPages}...`);
|
||||
|
||||
const response = await this.client.get('/adblbestsellers', {
|
||||
params: page > 1 ? { page } : {},
|
||||
@@ -105,11 +109,11 @@ export class AudibleService {
|
||||
foundOnPage++;
|
||||
});
|
||||
|
||||
console.log(`[Audible] Found ${foundOnPage} audiobooks on page ${page}`);
|
||||
logger.info(` Found ${foundOnPage} audiobooks on page ${page}`);
|
||||
|
||||
// If we got fewer than expected, probably no more pages
|
||||
if (foundOnPage < 10) {
|
||||
console.log(`[Audible] Reached end of available pages`);
|
||||
logger.info(` Reached end of available pages`);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -121,10 +125,10 @@ export class AudibleService {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Audible] Found ${audiobooks.length} popular audiobooks across ${page} pages`);
|
||||
logger.info(` Found ${audiobooks.length} popular audiobooks across ${page} pages`);
|
||||
return audiobooks;
|
||||
} catch (error) {
|
||||
console.error('[Audible] Failed to fetch popular audiobooks:', error);
|
||||
logger.error('Failed to fetch popular audiobooks', { error: error instanceof Error ? error.message : String(error) });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -134,14 +138,14 @@ export class AudibleService {
|
||||
*/
|
||||
async getNewReleases(limit: number = 20): Promise<AudibleAudiobook[]> {
|
||||
try {
|
||||
console.log(`[Audible] Fetching new releases (limit: ${limit})...`);
|
||||
logger.info(` Fetching new releases (limit: ${limit})...`);
|
||||
|
||||
const audiobooks: AudibleAudiobook[] = [];
|
||||
let page = 1;
|
||||
const maxPages = Math.ceil(limit / 20); // Audible shows ~20 items per page
|
||||
|
||||
while (audiobooks.length < limit && page <= maxPages) {
|
||||
console.log(`[Audible] Fetching page ${page}/${maxPages}...`);
|
||||
logger.info(` Fetching page ${page}/${maxPages}...`);
|
||||
|
||||
const response = await this.client.get('/newreleases', {
|
||||
params: page > 1 ? { page } : {},
|
||||
@@ -190,11 +194,11 @@ export class AudibleService {
|
||||
foundOnPage++;
|
||||
});
|
||||
|
||||
console.log(`[Audible] Found ${foundOnPage} audiobooks on page ${page}`);
|
||||
logger.info(` Found ${foundOnPage} audiobooks on page ${page}`);
|
||||
|
||||
// If we got fewer than expected, probably no more pages
|
||||
if (foundOnPage < 10) {
|
||||
console.log(`[Audible] Reached end of available pages`);
|
||||
logger.info(` Reached end of available pages`);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -206,10 +210,10 @@ export class AudibleService {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Audible] Found ${audiobooks.length} new releases across ${page} pages`);
|
||||
logger.info(` Found ${audiobooks.length} new releases across ${page} pages`);
|
||||
return audiobooks;
|
||||
} catch (error) {
|
||||
console.error('[Audible] Failed to fetch new releases:', error);
|
||||
logger.error('Failed to fetch new releases', { error: error instanceof Error ? error.message : String(error) });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -219,7 +223,7 @@ export class AudibleService {
|
||||
*/
|
||||
async search(query: string, page: number = 1): Promise<AudibleSearchResult> {
|
||||
try {
|
||||
console.log(`[Audible] Searching for "${query}"...`);
|
||||
logger.info(` Searching for "${query}"...`);
|
||||
|
||||
const response = await this.client.get('/search', {
|
||||
params: {
|
||||
@@ -285,7 +289,7 @@ export class AudibleService {
|
||||
const resultsText = $('.resultsInfo').text().trim();
|
||||
const totalResults = parseInt(resultsText.match(/of ([\d,]+)/)?.[1]?.replace(/,/g, '') || '0');
|
||||
|
||||
console.log(`[Audible] Found ${audiobooks.length} results for "${query}"`);
|
||||
logger.info(` Found ${audiobooks.length} results for "${query}"`);
|
||||
|
||||
return {
|
||||
query,
|
||||
@@ -295,7 +299,7 @@ export class AudibleService {
|
||||
hasMore: audiobooks.length > 0 && totalResults > page * 20,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Audible] Search failed:', error);
|
||||
logger.error('Search failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
return {
|
||||
query,
|
||||
results: [],
|
||||
@@ -313,21 +317,21 @@ export class AudibleService {
|
||||
*/
|
||||
async getAudiobookDetails(asin: string): Promise<AudibleAudiobook | null> {
|
||||
try {
|
||||
console.log(`[Audible] Fetching details for ASIN ${asin}...`);
|
||||
logger.info(` Fetching details for ASIN ${asin}...`);
|
||||
|
||||
// Try Audnexus first (more reliable)
|
||||
const audnexusData = await this.fetchFromAudnexus(asin);
|
||||
if (audnexusData) {
|
||||
console.log(`[Audible] Successfully fetched from Audnexus for "${audnexusData.title}"`);
|
||||
logger.info(` Successfully fetched from Audnexus for "${audnexusData.title}"`);
|
||||
return audnexusData;
|
||||
}
|
||||
|
||||
console.log(`[Audible] Audnexus failed, falling back to Audible scraping...`);
|
||||
logger.info(` Audnexus failed, falling back to Audible scraping...`);
|
||||
|
||||
// Fallback to Audible scraping
|
||||
return await this.scrapeAudibleDetails(asin);
|
||||
} catch (error) {
|
||||
console.error(`[Audible] Failed to fetch details for ${asin}:`, error);
|
||||
logger.error(`Failed to fetch details for ${asin}`, { error: error instanceof Error ? error.message : String(error) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -337,7 +341,7 @@ export class AudibleService {
|
||||
*/
|
||||
private async fetchFromAudnexus(asin: string): Promise<AudibleAudiobook | null> {
|
||||
try {
|
||||
console.log(`[Audnexus] Fetching ASIN ${asin}...`);
|
||||
logger.debug(`Fetching ASIN from Audnexus: ${asin}`);
|
||||
|
||||
const response = await axios.get(`https://api.audnex.us/books/${asin}`, {
|
||||
timeout: 10000,
|
||||
@@ -367,22 +371,22 @@ export class AudibleService {
|
||||
result.coverArtUrl = result.coverArtUrl.replace(/\._.*_\./, '._SL500_.');
|
||||
}
|
||||
|
||||
console.log(`[Audnexus] Success:`, JSON.stringify({
|
||||
logger.debug('Audnexus success', {
|
||||
title: result.title,
|
||||
author: result.author,
|
||||
narrator: result.narrator,
|
||||
descLength: result.description?.length || 0,
|
||||
duration: result.durationMinutes,
|
||||
rating: result.rating,
|
||||
genres: result.genres?.length || 0
|
||||
}));
|
||||
genreCount: result.genres?.length || 0
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 404) {
|
||||
console.log(`[Audnexus] Book not found (404) for ASIN ${asin}`);
|
||||
logger.debug(`Book not found (404) on Audnexus for ASIN ${asin}`);
|
||||
} else {
|
||||
console.log(`[Audnexus] Error fetching ASIN ${asin}:`, error.message);
|
||||
logger.warn(`Error fetching from Audnexus for ASIN ${asin}`, { error: error.message });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -413,20 +417,20 @@ export class AudibleService {
|
||||
const path = require('path');
|
||||
const debugPath = path.join('/tmp', `audible-${asin}.html`);
|
||||
fs.writeFileSync(debugPath, response.data);
|
||||
console.log(`[Audible] Saved HTML to ${debugPath} for debugging`);
|
||||
logger.info(` Saved HTML to ${debugPath} for debugging`);
|
||||
}
|
||||
|
||||
// Try to extract JSON-LD structured data first
|
||||
const jsonLdScripts = $('script[type="application/ld+json"]');
|
||||
console.log(`[Audible] Found ${jsonLdScripts.length} JSON-LD script tags`);
|
||||
logger.info(` Found ${jsonLdScripts.length} JSON-LD script tags`);
|
||||
|
||||
jsonLdScripts.each((i, elem) => {
|
||||
try {
|
||||
const jsonData = JSON.parse($(elem).html() || '{}');
|
||||
console.log(`[Audible] JSON-LD ${i} type:`, jsonData['@type']);
|
||||
logger.info(` JSON-LD ${i} type:`, jsonData['@type']);
|
||||
|
||||
if (jsonData['@type'] === 'Book' || jsonData['@type'] === 'Audiobook' || jsonData['@type'] === 'Product') {
|
||||
console.log('[Audible] Found valid JSON-LD structured data');
|
||||
logger.debug('Found valid JSON-LD structured data');
|
||||
|
||||
if (jsonData.name) result.title = jsonData.name;
|
||||
|
||||
@@ -455,7 +459,7 @@ export class AudibleService {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`[Audible] JSON-LD ${i} parsing failed:`, e);
|
||||
logger.debug(`JSON-LD ${i} parsing failed`, { error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -466,7 +470,7 @@ export class AudibleService {
|
||||
$('h1[class*="heading"]').first().text().trim() ||
|
||||
$('.bc-container h1').first().text().trim() ||
|
||||
$('h1').first().text().trim();
|
||||
console.log(`[Audible] Title from HTML: "${result.title}"`);
|
||||
logger.info(` Title from HTML: "${result.title}"`);
|
||||
}
|
||||
|
||||
// Author - try multiple approaches (only in product details area)
|
||||
@@ -502,7 +506,7 @@ export class AudibleService {
|
||||
}
|
||||
|
||||
result.author = result.author.replace(/^By:\s*/i, '').replace(/^Written by:\s*/i, '').trim();
|
||||
console.log(`[Audible] Author from HTML: "${result.author}"`);
|
||||
logger.info(` Author from HTML: "${result.author}"`);
|
||||
}
|
||||
|
||||
// Narrator - try multiple approaches (only in product details area)
|
||||
@@ -538,7 +542,7 @@ export class AudibleService {
|
||||
if (result.narrator) {
|
||||
result.narrator = result.narrator.replace(/^Narrated by:\s*/i, '').trim();
|
||||
}
|
||||
console.log(`[Audible] Narrator from HTML: "${result.narrator || ''}"`);
|
||||
logger.info(` Narrator from HTML: "${result.narrator || ''}"`);
|
||||
}
|
||||
|
||||
// Description - try multiple approaches with strict filtering
|
||||
@@ -588,7 +592,7 @@ export class AudibleService {
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[Audible] Description length: ${result.description?.length || 0} chars`);
|
||||
logger.info(` Description length: ${result.description?.length || 0} chars`);
|
||||
}
|
||||
|
||||
// Cover art - try multiple selectors
|
||||
@@ -627,7 +631,7 @@ export class AudibleService {
|
||||
})();
|
||||
|
||||
result.durationMinutes = this.parseRuntime(runtimeText);
|
||||
console.log(`[Audible] Duration from "${runtimeText}": ${result.durationMinutes} minutes`);
|
||||
logger.info(` Duration from "${runtimeText}": ${result.durationMinutes} minutes`);
|
||||
}
|
||||
|
||||
// Rating - try multiple approaches
|
||||
@@ -653,7 +657,7 @@ export class AudibleService {
|
||||
const ratingMatch = ratingText.match(/(\d+\.?\d*)\s*out of/i);
|
||||
result.rating = ratingMatch ? parseFloat(ratingMatch[1]) : undefined;
|
||||
}
|
||||
console.log(`[Audible] Rating from "${ratingText}": ${result.rating}`);
|
||||
logger.info(` Rating from "${ratingText}": ${result.rating}`);
|
||||
}
|
||||
|
||||
// Release date - try multiple selectors
|
||||
@@ -668,7 +672,7 @@ export class AudibleService {
|
||||
if (dateMatch) {
|
||||
result.releaseDate = dateMatch[1].trim();
|
||||
}
|
||||
console.log(`[Audible] Release date from "${releaseDateText}": ${result.releaseDate}`);
|
||||
logger.info(` Release date from "${releaseDateText}": ${result.releaseDate}`);
|
||||
}
|
||||
|
||||
// Genres - try to extract categories
|
||||
@@ -681,23 +685,23 @@ export class AudibleService {
|
||||
});
|
||||
if (genres.length > 0) {
|
||||
result.genres = genres.slice(0, 5); // Limit to 5 genres
|
||||
console.log(`[Audible] Genres: ${result.genres.join(', ')}`);
|
||||
logger.info(` Genres: ${result.genres.join(', ')}`);
|
||||
}
|
||||
|
||||
console.log(`[Audible] Successfully fetched details for "${result.title}"`);
|
||||
console.log(`[Audible] Final result:`, JSON.stringify({
|
||||
logger.info(`Successfully fetched details for "${result.title}"`);
|
||||
logger.debug('Final result', {
|
||||
title: result.title,
|
||||
author: result.author,
|
||||
narrator: result.narrator,
|
||||
descLength: result.description?.length || 0,
|
||||
duration: result.durationMinutes,
|
||||
rating: result.rating,
|
||||
genres: result.genres?.length || 0
|
||||
}));
|
||||
genreCount: result.genres?.length || 0
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`[Audible] Failed to fetch details for ${asin}:`, error);
|
||||
logger.error(`Failed to fetch details for ${asin}`, { error: error instanceof Error ? error.message : String(error) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import { parseStringPromise } from 'xml2js';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
// Module-level logger
|
||||
const logger = RMABLogger.create('Plex');
|
||||
|
||||
const PLEX_TV_API_BASE = 'https://plex.tv/api/v2';
|
||||
const PLEX_CLIENT_IDENTIFIER = process.env.PLEX_CLIENT_IDENTIFIER || 'readmeabook-unique-client-id';
|
||||
@@ -106,7 +110,7 @@ export class PlexService {
|
||||
code: response.data.code,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to request Plex PIN:', error);
|
||||
logger.error('Failed to request PIN', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw new Error('Failed to request authentication PIN from Plex');
|
||||
}
|
||||
}
|
||||
@@ -125,7 +129,7 @@ export class PlexService {
|
||||
|
||||
return response.data.authToken || null;
|
||||
} catch (error) {
|
||||
console.error('Failed to check Plex PIN:', error);
|
||||
logger.error('Failed to check PIN', { error: error instanceof Error ? error.message : String(error) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -147,36 +151,36 @@ export class PlexService {
|
||||
// Handle different response formats from Plex
|
||||
if (typeof response.data === 'string') {
|
||||
// XML response - parse it
|
||||
console.log('[Plex] Received XML response, parsing...');
|
||||
logger.debug('Received XML response, parsing...');
|
||||
const parsed = await parseStringPromise(response.data);
|
||||
|
||||
// XML attributes are in user.$
|
||||
if (parsed.user && parsed.user.$) {
|
||||
userData = parsed.user.$;
|
||||
} else {
|
||||
console.error('[Plex] Unexpected XML structure:', parsed);
|
||||
logger.error('Unexpected XML structure', { parsed });
|
||||
throw new Error('Unexpected XML structure in Plex response');
|
||||
}
|
||||
} else if (response.data && typeof response.data === 'object') {
|
||||
// JSON response
|
||||
console.log('[Plex] Received JSON response');
|
||||
logger.debug('Received JSON response');
|
||||
userData = response.data;
|
||||
} else {
|
||||
console.error('[Plex] Unexpected response type:', typeof response.data);
|
||||
logger.error('Unexpected response type', { type: typeof response.data });
|
||||
throw new Error('Unexpected response format from Plex');
|
||||
}
|
||||
|
||||
console.log('[Plex] Parsed user data:', JSON.stringify(userData, null, 2));
|
||||
logger.debug('Parsed user data', { userData });
|
||||
|
||||
// Validate required fields
|
||||
if (!userData.id) {
|
||||
console.error('[Plex] User ID missing from parsed data:', userData);
|
||||
logger.error('User ID missing from parsed data', { userData });
|
||||
throw new Error('User ID missing from Plex response');
|
||||
}
|
||||
|
||||
const username = userData.username || userData.title;
|
||||
if (!username) {
|
||||
console.error('[Plex] Username missing from parsed data:', userData);
|
||||
logger.error('Username missing from parsed data', { userData });
|
||||
throw new Error('Username missing from Plex response');
|
||||
}
|
||||
|
||||
@@ -188,7 +192,7 @@ export class PlexService {
|
||||
authToken,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get Plex user info:', error);
|
||||
logger.error('Failed to get user info', { error: error instanceof Error ? error.message : String(error) });
|
||||
if (error instanceof Error) {
|
||||
throw error; // Re-throw our custom errors
|
||||
}
|
||||
@@ -237,7 +241,7 @@ export class PlexService {
|
||||
// else data is already the right format
|
||||
}
|
||||
|
||||
console.log('[Plex] Identity response:', JSON.stringify(data, null, 2));
|
||||
logger.debug('Identity response', { data });
|
||||
|
||||
const info: PlexServerInfo = {
|
||||
machineIdentifier: data.machineIdentifier || 'unknown',
|
||||
@@ -252,7 +256,7 @@ export class PlexService {
|
||||
info,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Plex connection test failed:', error);
|
||||
logger.error('Connection test failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Could not connect to Plex server. Check server URL and token.',
|
||||
@@ -275,7 +279,7 @@ export class PlexService {
|
||||
userPlexToken: string
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
console.log('[Plex] Fetching server access token for machineId:', serverMachineId);
|
||||
logger.debug('Fetching server access token', { serverMachineId });
|
||||
|
||||
// Get the list of servers/resources the user has access to
|
||||
const response = await this.client.get('https://plex.tv/api/v2/resources', {
|
||||
@@ -300,20 +304,20 @@ export class PlexService {
|
||||
});
|
||||
|
||||
if (!serverResource) {
|
||||
console.warn('[Plex] User does not have access to server:', serverMachineId);
|
||||
logger.warn('User does not have access to server', { serverMachineId });
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!serverResource.accessToken) {
|
||||
console.error('[Plex] Server resource found but no accessToken provided');
|
||||
logger.error('Server resource found but no accessToken provided');
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('[Plex] Found server access token for:', serverResource.name);
|
||||
logger.debug('Found server access token', { serverName: serverResource.name });
|
||||
return serverResource.accessToken;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Plex] Failed to fetch server access token:', error);
|
||||
logger.error('Failed to fetch server access token', { error: error instanceof Error ? error.message : String(error) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -327,7 +331,7 @@ export class PlexService {
|
||||
*/
|
||||
async verifyServerAccess(serverUrl: string, serverMachineId: string, userToken: string): Promise<boolean> {
|
||||
try {
|
||||
console.log('[Plex] Verifying server access for machineId:', serverMachineId);
|
||||
logger.debug('Verifying server access', { serverMachineId });
|
||||
|
||||
// Get the list of servers/resources the user has access to
|
||||
const response = await this.client.get('https://plex.tv/api/v2/resources', {
|
||||
@@ -344,21 +348,19 @@ export class PlexService {
|
||||
});
|
||||
|
||||
const resources = response.data || [];
|
||||
console.log('[Plex] User has access to', resources.length, 'resources');
|
||||
logger.debug('User has access to resources', { count: resources.length });
|
||||
|
||||
// Log all resources for debugging
|
||||
console.log('[Plex] User accessible resources:', JSON.stringify(
|
||||
resources.map((r: any) => ({
|
||||
logger.debug('User accessible resources', {
|
||||
resources: resources.map((r: any) => ({
|
||||
name: r.name,
|
||||
product: r.product,
|
||||
provides: r.provides,
|
||||
clientIdentifier: r.clientIdentifier,
|
||||
machineIdentifier: r.machineIdentifier,
|
||||
owned: r.owned,
|
||||
})),
|
||||
null,
|
||||
2
|
||||
));
|
||||
}))
|
||||
});
|
||||
|
||||
// Filter to only server resources (not clients like apps)
|
||||
const servers = resources.filter((r: any) =>
|
||||
@@ -367,14 +369,14 @@ export class PlexService {
|
||||
(r.provides && r.provides.includes && r.provides.includes('server'))
|
||||
);
|
||||
|
||||
console.log('[Plex] Found', servers.length, 'server resources');
|
||||
logger.debug('Found server resources', { count: servers.length });
|
||||
|
||||
// Check if our server is in the list of accessible resources
|
||||
const hasAccess = servers.some((resource: any) => {
|
||||
const resourceId = resource.clientIdentifier || resource.machineIdentifier;
|
||||
const match = resourceId === serverMachineId;
|
||||
|
||||
console.log('[Plex] Comparing:', {
|
||||
logger.debug('Comparing resource', {
|
||||
resourceId,
|
||||
serverMachineId,
|
||||
match,
|
||||
@@ -382,7 +384,7 @@ export class PlexService {
|
||||
});
|
||||
|
||||
if (match) {
|
||||
console.log('[Plex] ✓ Found matching server:', {
|
||||
logger.debug('Found matching server', {
|
||||
name: resource.name,
|
||||
machineId: resourceId,
|
||||
owned: resource.owned,
|
||||
@@ -393,23 +395,23 @@ export class PlexService {
|
||||
});
|
||||
|
||||
if (!hasAccess) {
|
||||
console.warn('[Plex] ✗ Server not found in user\'s accessible resources');
|
||||
console.warn('[Plex] Looking for machineId:', serverMachineId);
|
||||
console.warn('[Plex] User has access to servers:',
|
||||
servers.map((r: any) => ({
|
||||
logger.warn('Server not found in user accessible resources', {
|
||||
serverMachineId,
|
||||
accessibleServers: servers.map((r: any) => ({
|
||||
name: r.name,
|
||||
clientId: r.clientIdentifier,
|
||||
machineId: r.machineIdentifier,
|
||||
}))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return hasAccess;
|
||||
} catch (error: any) {
|
||||
console.error('[Plex] Failed to verify server access:', error.response?.status || error.message);
|
||||
if (error.response?.data) {
|
||||
console.error('[Plex] Error response:', error.response.data);
|
||||
}
|
||||
logger.error('Failed to verify server access', {
|
||||
status: error.response?.status,
|
||||
error: error.message,
|
||||
responseData: error.response?.data
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -456,7 +458,7 @@ export class PlexService {
|
||||
|
||||
return libraries;
|
||||
} catch (error) {
|
||||
console.error('Failed to get Plex libraries:', error);
|
||||
logger.error('Failed to get libraries', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw new Error('Failed to retrieve libraries from Plex server');
|
||||
}
|
||||
}
|
||||
@@ -488,27 +490,27 @@ export class PlexService {
|
||||
}
|
||||
);
|
||||
|
||||
console.log('[Plex] Recently added response type:', typeof response.data);
|
||||
logger.debug('Recently added response type', { type: typeof response.data });
|
||||
|
||||
// Handle XML response
|
||||
let data = response.data;
|
||||
if (typeof data === 'string') {
|
||||
console.log('[Plex] Parsing XML response...');
|
||||
logger.debug('Parsing XML response...');
|
||||
const parsed = await parseStringPromise(data);
|
||||
data = parsed.MediaContainer;
|
||||
} else if (data && typeof data === 'object') {
|
||||
// JSON response - could be wrapped in MediaContainer
|
||||
if (data.MediaContainer) {
|
||||
console.log('[Plex] Extracting from MediaContainer wrapper');
|
||||
logger.debug('Extracting from MediaContainer wrapper');
|
||||
data = data.MediaContainer;
|
||||
}
|
||||
}
|
||||
|
||||
const tracks = data.Metadata || data.Track || data.Directory || data.Album || [];
|
||||
console.log('[Plex] Found', Array.isArray(tracks) ? tracks.length : '(not an array)', 'recently added items');
|
||||
logger.debug('Found recently added items', { count: Array.isArray(tracks) ? tracks.length : 'not an array' });
|
||||
|
||||
if (!Array.isArray(tracks)) {
|
||||
console.warn('[Plex] tracks is not an array:', tracks);
|
||||
logger.warn('tracks is not an array', { tracks });
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -527,7 +529,7 @@ export class PlexService {
|
||||
userRating: item.userRating ? parseFloat(item.userRating) : (item.$?.userRating ? parseFloat(item.$?.userRating) : undefined),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to get recently added content:', error);
|
||||
logger.error('Failed to get recently added content', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw new Error('Failed to retrieve recently added content from Plex library');
|
||||
}
|
||||
}
|
||||
@@ -554,30 +556,29 @@ export class PlexService {
|
||||
}
|
||||
);
|
||||
|
||||
console.log('[Plex] Library content response type:', typeof response.data);
|
||||
logger.debug('Library content response type', { type: typeof response.data });
|
||||
|
||||
// Handle XML response
|
||||
let data = response.data;
|
||||
if (typeof data === 'string') {
|
||||
console.log('[Plex] Parsing XML response...');
|
||||
logger.debug('Parsing XML response...');
|
||||
const parsed = await parseStringPromise(data);
|
||||
data = parsed.MediaContainer;
|
||||
} else if (data && typeof data === 'object') {
|
||||
// JSON response - could be wrapped in MediaContainer
|
||||
if (data.MediaContainer) {
|
||||
console.log('[Plex] Extracting from MediaContainer wrapper');
|
||||
logger.debug('Extracting from MediaContainer wrapper');
|
||||
data = data.MediaContainer;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Plex] Data structure keys:', Object.keys(data || {}));
|
||||
console.log('[Plex] Looking for content in: Metadata, Track, Directory, Album');
|
||||
logger.debug('Data structure', { keys: Object.keys(data || {}) });
|
||||
|
||||
const tracks = data.Metadata || data.Track || data.Directory || data.Album || [];
|
||||
console.log('[Plex] Found', Array.isArray(tracks) ? tracks.length : '(not an array)', 'items');
|
||||
logger.debug('Found items', { count: Array.isArray(tracks) ? tracks.length : 'not an array' });
|
||||
|
||||
if (!Array.isArray(tracks)) {
|
||||
console.warn('[Plex] tracks is not an array:', tracks);
|
||||
logger.warn('tracks is not an array', { tracks });
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -597,9 +598,9 @@ export class PlexService {
|
||||
}));
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 401) {
|
||||
console.error('[Plex] 401 Unauthorized when fetching library content - token may not have server access permissions');
|
||||
logger.error('401 Unauthorized when fetching library content - token may not have server access permissions');
|
||||
} else {
|
||||
console.error('[Plex] Failed to get library content:', error);
|
||||
logger.error('Failed to get library content', { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
throw new Error('Failed to retrieve content from Plex library');
|
||||
}
|
||||
@@ -616,9 +617,9 @@ export class PlexService {
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Triggered Plex library scan for library ${libraryId}`);
|
||||
logger.info(`Triggered library scan for library ${libraryId}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to trigger Plex scan:', error);
|
||||
logger.error('Failed to trigger scan', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw new Error('Failed to trigger Plex library scan');
|
||||
}
|
||||
}
|
||||
@@ -665,7 +666,7 @@ export class PlexService {
|
||||
updatedAt: item.updatedAt ? parseInt(item.updatedAt) : Date.now(),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to search Plex library:', error);
|
||||
logger.error('Failed to search library', { error: error instanceof Error ? error.message : String(error) });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -717,15 +718,15 @@ export class PlexService {
|
||||
} catch (error: any) {
|
||||
// Handle 401 specifically (expired or invalid token)
|
||||
if (error.response?.status === 401) {
|
||||
console.warn(`[Plex] User token unauthorized for ratingKey ${ratingKey} (token may be expired or invalid)`);
|
||||
logger.warn('User token unauthorized', { ratingKey, reason: 'token may be expired or invalid' });
|
||||
return null;
|
||||
}
|
||||
// Handle 404 (item not found or user doesn't have access)
|
||||
if (error.response?.status === 404) {
|
||||
console.warn(`[Plex] Item not found or no access: ratingKey ${ratingKey}`);
|
||||
logger.warn('Item not found or no access', { ratingKey });
|
||||
return null;
|
||||
}
|
||||
console.error(`[Plex] Failed to get metadata for ratingKey ${ratingKey}:`, error.message || error);
|
||||
logger.error('Failed to get metadata', { ratingKey, error: error.message || String(error) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -765,9 +766,9 @@ export class PlexService {
|
||||
|
||||
// If we got many 401s, log a warning about token issues
|
||||
if (unauthorizedCount > 0) {
|
||||
console.warn(`[Plex] ${unauthorizedCount} of ${ratingKeys.length} items returned 401 (user token may be expired or invalid)`);
|
||||
logger.warn('Some rating requests failed with 401', { unauthorizedCount, totalCount: ratingKeys.length });
|
||||
if (unauthorizedCount === ratingKeys.length) {
|
||||
console.error('[Plex] All rating requests failed with 401 - user needs to re-authenticate with Plex');
|
||||
logger.error('All rating requests failed with 401 - user needs to re-authenticate');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -780,7 +781,7 @@ export class PlexService {
|
||||
*/
|
||||
async getHomeUsers(authToken: string): Promise<PlexHomeUser[]> {
|
||||
try {
|
||||
console.log('[Plex] Fetching home users from plex.tv/api/home/users');
|
||||
logger.debug('Fetching home users');
|
||||
const response = await this.client.get(
|
||||
'https://plex.tv/api/home/users',
|
||||
{
|
||||
@@ -792,36 +793,36 @@ export class PlexService {
|
||||
}
|
||||
);
|
||||
|
||||
console.log('[Plex] Home users API response status:', response.status);
|
||||
console.log('[Plex] Home users API response type:', typeof response.data);
|
||||
logger.debug('Home users API response', { status: response.status, type: typeof response.data });
|
||||
|
||||
// Handle XML response
|
||||
let data = response.data;
|
||||
if (typeof data === 'string') {
|
||||
console.log('[Plex] Response is XML string, parsing...');
|
||||
logger.debug('Response is XML string, parsing...');
|
||||
const parsed = await parseStringPromise(data);
|
||||
data = parsed;
|
||||
console.log('[Plex] Parsed XML structure:', JSON.stringify(data, null, 2));
|
||||
logger.debug('Parsed XML structure', { data });
|
||||
} else {
|
||||
console.log('[Plex] Response is JSON, structure:', JSON.stringify(data, null, 2));
|
||||
logger.debug('Response is JSON', { data });
|
||||
}
|
||||
|
||||
// Extract users from response
|
||||
// Response structure: { home: { users: [{ user: {...} }] } } or similar
|
||||
const users: any[] = [];
|
||||
|
||||
console.log('[Plex] Checking for users in response...');
|
||||
console.log('[Plex] data.MediaContainer exists?', !!data.MediaContainer);
|
||||
console.log('[Plex] data.MediaContainer?.User exists?', !!data.MediaContainer?.User);
|
||||
console.log('[Plex] data.home exists?', !!data.home);
|
||||
console.log('[Plex] data.home?.users exists?', !!data.home?.users);
|
||||
console.log('[Plex] data.users exists?', !!data.users);
|
||||
logger.debug('Checking for users in response', {
|
||||
hasMediaContainer: !!data.MediaContainer,
|
||||
hasMediaContainerUser: !!data.MediaContainer?.User,
|
||||
hasHome: !!data.home,
|
||||
hasHomeUsers: !!data.home?.users,
|
||||
hasUsers: !!data.users
|
||||
});
|
||||
|
||||
// Check for users in MediaContainer.User (XML response structure)
|
||||
if (data.MediaContainer?.User) {
|
||||
console.log('[Plex] Found users in data.MediaContainer.User');
|
||||
logger.debug('Found users in data.MediaContainer.User');
|
||||
const usersList = Array.isArray(data.MediaContainer.User) ? data.MediaContainer.User : [data.MediaContainer.User];
|
||||
console.log('[Plex] usersList length:', usersList.length);
|
||||
logger.debug('usersList length', { count: usersList.length });
|
||||
usersList.forEach((item: any) => {
|
||||
// XML parsed data has attributes in the $ property
|
||||
if (item.$) {
|
||||
@@ -831,9 +832,9 @@ export class PlexService {
|
||||
}
|
||||
});
|
||||
} else if (data.home?.users) {
|
||||
console.log('[Plex] Found users in data.home.users');
|
||||
logger.debug('Found users in data.home.users');
|
||||
const usersList = Array.isArray(data.home.users) ? data.home.users : [data.home.users];
|
||||
console.log('[Plex] usersList length:', usersList.length);
|
||||
logger.debug('usersList length', { count: usersList.length });
|
||||
usersList.forEach((item: any) => {
|
||||
if (item.user) {
|
||||
users.push(item.user);
|
||||
@@ -844,9 +845,9 @@ export class PlexService {
|
||||
}
|
||||
});
|
||||
} else if (data.users) {
|
||||
console.log('[Plex] Found users in data.users');
|
||||
logger.debug('Found users in data.users');
|
||||
const usersList = Array.isArray(data.users) ? data.users : [data.users];
|
||||
console.log('[Plex] usersList length:', usersList.length);
|
||||
logger.debug('usersList length', { count: usersList.length });
|
||||
usersList.forEach((item: any) => {
|
||||
if (item.user) {
|
||||
users.push(item.user);
|
||||
@@ -857,14 +858,13 @@ export class PlexService {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log('[Plex] No users found in expected locations. Full data structure:');
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
logger.debug('No users found in expected locations', { data });
|
||||
}
|
||||
|
||||
console.log('[Plex] Extracted', users.length, 'users from response');
|
||||
logger.debug('Extracted users from response', { count: users.length });
|
||||
|
||||
if (users.length === 0) {
|
||||
console.warn('[Plex] No home users found - this account may not have a Plex Home setup');
|
||||
logger.warn('No home users found - account may not have Plex Home setup');
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -898,11 +898,11 @@ export class PlexService {
|
||||
};
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[Plex] Failed to get home users:', error.message || error);
|
||||
if (error.response) {
|
||||
console.error('[Plex] Error response status:', error.response.status);
|
||||
console.error('[Plex] Error response data:', error.response.data);
|
||||
}
|
||||
logger.error('Failed to get home users', {
|
||||
error: error.message || String(error),
|
||||
status: error.response?.status,
|
||||
responseData: error.response?.data
|
||||
});
|
||||
// Return empty array if no home users (not an error condition)
|
||||
return [];
|
||||
}
|
||||
@@ -958,7 +958,7 @@ export class PlexService {
|
||||
}
|
||||
|
||||
if (!authenticationToken) {
|
||||
console.error('[Plex] No authenticationToken found in switch response:', JSON.stringify(data, null, 2));
|
||||
logger.error('No authenticationToken found in switch response', { data });
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -966,10 +966,10 @@ export class PlexService {
|
||||
} catch (error: any) {
|
||||
// Handle PIN errors specifically
|
||||
if (error.response?.status === 401) {
|
||||
console.error('[Plex] Invalid PIN for profile');
|
||||
logger.error('Invalid PIN for profile');
|
||||
throw new Error('Invalid PIN');
|
||||
}
|
||||
console.error('[Plex] Failed to switch home user:', error);
|
||||
logger.error('Failed to switch home user', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw new Error('Failed to switch to selected profile');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { TorrentResult } from '../utils/ranking-algorithm';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
// Module-level logger
|
||||
const logger = RMABLogger.create('Prowlarr');
|
||||
|
||||
export interface SearchFilters {
|
||||
category?: number;
|
||||
@@ -96,8 +100,7 @@ export class ProwlarrService {
|
||||
|
||||
// Debug interceptor to log actual outgoing requests
|
||||
this.client.interceptors.request.use((config) => {
|
||||
console.log(`[Prowlarr] Actual request: ${config.method?.toUpperCase()} ${config.baseURL}${config.url}`);
|
||||
console.log(`[Prowlarr] Request params:`, JSON.stringify(config.params));
|
||||
logger.debug(`Actual request: ${config.method?.toUpperCase()} ${config.baseURL}${config.url}`, { params: config.params });
|
||||
return config;
|
||||
});
|
||||
}
|
||||
@@ -130,12 +133,12 @@ export class ProwlarrService {
|
||||
}
|
||||
|
||||
const response = await this.client.get('/search', { params });
|
||||
console.log(`[Prowlarr] Raw API response: ${response.data.length} results`);
|
||||
logger.info(` Raw API response: ${response.data.length} results`);
|
||||
|
||||
// Debug: Log first raw result to see structure and protocol field
|
||||
if (response.data.length > 0) {
|
||||
const firstResult = response.data[0];
|
||||
console.log(`[Prowlarr] First raw result - protocol: "${firstResult.protocol}", indexer: "${firstResult.indexer}", title: "${firstResult.title?.substring(0, 50)}..."`);
|
||||
logger.info(` First raw result - protocol: "${firstResult.protocol}", indexer: "${firstResult.indexer}", title: "${firstResult.title?.substring(0, 50)}..."`);
|
||||
|
||||
// Check protocol distribution in raw results
|
||||
const rawProtocols = response.data.reduce((acc: Record<string, number>, r: any) => {
|
||||
@@ -143,21 +146,21 @@ export class ProwlarrService {
|
||||
acc[proto] = (acc[proto] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
console.log(`[Prowlarr] Raw protocol distribution:`, JSON.stringify(rawProtocols));
|
||||
logger.info(`Raw protocol distribution`, { protocols: rawProtocols });
|
||||
}
|
||||
|
||||
// Debug: Log first raw result full structure (debug mode only)
|
||||
if (process.env.LOG_LEVEL === 'debug' && response.data.length > 0) {
|
||||
console.log('[Prowlarr] Sample raw result from API:', JSON.stringify(response.data[0], null, 2));
|
||||
// Debug: Log first raw result full structure (automatically filtered by LOG_LEVEL)
|
||||
if (response.data.length > 0) {
|
||||
logger.debug('Sample raw result from API', response.data[0]);
|
||||
}
|
||||
|
||||
// Transform Prowlarr results to our format
|
||||
const results = response.data
|
||||
.map((result: ProwlarrSearchResult, index: number) => {
|
||||
const transformed = this.transformResult(result);
|
||||
if (!transformed && process.env.LOG_LEVEL === 'debug') {
|
||||
// Log the full raw result that was skipped (debug mode only)
|
||||
console.log(`[Prowlarr] Result #${index + 1} was skipped. Raw data:`, JSON.stringify(result, null, 2));
|
||||
if (!transformed) {
|
||||
// Log the full raw result that was skipped (automatically filtered by LOG_LEVEL)
|
||||
logger.debug(`Result #${index + 1} was skipped`, { rawData: result });
|
||||
}
|
||||
return transformed;
|
||||
})
|
||||
@@ -181,11 +184,11 @@ export class ProwlarrService {
|
||||
filtered = filtered.slice(0, filters.maxResults);
|
||||
}
|
||||
|
||||
console.log(`Prowlarr search for "${query}" returned ${filtered.length} results`);
|
||||
logger.info(`Search for "${query}" returned ${filtered.length} results`);
|
||||
|
||||
return filtered;
|
||||
} catch (error) {
|
||||
console.error('Prowlarr search failed:', error);
|
||||
logger.error('Search failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw new Error(
|
||||
`Failed to search Prowlarr: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
@@ -200,7 +203,7 @@ export class ProwlarrService {
|
||||
const response = await this.client.get('/indexer');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to get Prowlarr indexers:', error);
|
||||
logger.error('Failed to get indexers', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw new Error('Failed to get indexers from Prowlarr');
|
||||
}
|
||||
}
|
||||
@@ -213,7 +216,7 @@ export class ProwlarrService {
|
||||
await this.client.get('/health');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Prowlarr connection test failed:', error);
|
||||
logger.error('Connection test failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -226,7 +229,7 @@ export class ProwlarrService {
|
||||
const response = await this.client.get('/indexerstats');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to get Prowlarr stats:', error);
|
||||
logger.error('Failed to get stats', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw new Error('Failed to get indexer statistics');
|
||||
}
|
||||
}
|
||||
@@ -292,7 +295,7 @@ export class ProwlarrService {
|
||||
|
||||
// Skip torrents without a valid download URL
|
||||
if (!downloadUrl || typeof downloadUrl !== 'string' || downloadUrl.trim() === '') {
|
||||
console.warn(`[Prowlarr] Skipping torrent "${item.title || 'Unknown'}" - missing download URL`);
|
||||
logger.warn(` Skipping torrent "${item.title || 'Unknown'}" - missing download URL`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -315,16 +318,16 @@ export class ProwlarrService {
|
||||
|
||||
results.push(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse RSS item:', error);
|
||||
logger.error('Failed to parse RSS item', { error: error instanceof Error ? error.message : String(error) });
|
||||
// Continue with other items
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`RSS feed for indexer ${indexerId} returned ${results.length} results`);
|
||||
logger.info(`RSS feed for indexer ${indexerId} returned ${results.length} results`);
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error(`Failed to get RSS feed for indexer ${indexerId}:`, error);
|
||||
logger.error(`Failed to get RSS feed for indexer ${indexerId}`, { error: error instanceof Error ? error.message : String(error) });
|
||||
throw new Error(`Failed to get RSS feed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
@@ -340,12 +343,12 @@ export class ProwlarrService {
|
||||
const results = await this.getRssFeed(indexerId);
|
||||
allResults.push(...results);
|
||||
} catch (error) {
|
||||
console.error(`Failed to get RSS feed for indexer ${indexerId}:`, error);
|
||||
logger.error(`Failed to get RSS feed for indexer ${indexerId}`, { error: error instanceof Error ? error.message : String(error) });
|
||||
// Continue with other indexers even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`RSS feeds from ${indexerIds.length} indexers returned ${allResults.length} total results`);
|
||||
logger.info(`RSS feeds from ${indexerIds.length} indexers returned ${allResults.length} total results`);
|
||||
|
||||
return allResults;
|
||||
}
|
||||
@@ -368,33 +371,33 @@ export class ProwlarrService {
|
||||
acc[proto] = (acc[proto] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
console.log(`[Prowlarr] Protocol distribution in ${results.length} results:`, JSON.stringify(protocolCounts));
|
||||
logger.debug(`Protocol distribution in ${results.length} results`, { protocols: protocolCounts });
|
||||
|
||||
// Debug: Log first few results to see their protocols
|
||||
if (results.length > 0 && results.length <= 5) {
|
||||
results.forEach((r, i) => {
|
||||
console.log(`[Prowlarr] Result ${i + 1}: protocol="${r.protocol || 'undefined'}", url="${r.downloadUrl.substring(0, 80)}..."`);
|
||||
logger.info(` Result ${i + 1}: protocol="${r.protocol || 'undefined'}", url="${r.downloadUrl.substring(0, 80)}..."`);
|
||||
});
|
||||
} else if (results.length > 5) {
|
||||
console.log(`[Prowlarr] First 3 results:`);
|
||||
logger.info(` First 3 results:`);
|
||||
results.slice(0, 3).forEach((r, i) => {
|
||||
console.log(`[Prowlarr] ${i + 1}: protocol="${r.protocol || 'undefined'}", isNZB=${ProwlarrService.isNZBResult(r)}`);
|
||||
logger.info(` ${i + 1}: protocol="${r.protocol || 'undefined'}", isNZB=${ProwlarrService.isNZBResult(r)}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (clientType === 'sabnzbd') {
|
||||
// Filter for NZB results only
|
||||
const filtered = results.filter(result => ProwlarrService.isNZBResult(result));
|
||||
console.log(`[Prowlarr] Filtered ${results.length} results to ${filtered.length} NZB results for SABnzbd`);
|
||||
logger.info(` Filtered ${results.length} results to ${filtered.length} NZB results for SABnzbd`);
|
||||
return filtered;
|
||||
} else {
|
||||
// Filter for torrent results only (default)
|
||||
const filtered = results.filter(result => !ProwlarrService.isNZBResult(result));
|
||||
console.log(`[Prowlarr] Filtered ${results.length} results to ${filtered.length} torrent results for qBittorrent`);
|
||||
logger.info(` Filtered ${results.length} results to ${filtered.length} torrent results for qBittorrent`);
|
||||
return filtered;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Prowlarr] Failed to filter by protocol, returning all results:', error);
|
||||
logger.error('Failed to filter by protocol, returning all results', { error: error instanceof Error ? error.message : String(error) });
|
||||
return results; // Fallback: return unfiltered if config fails
|
||||
}
|
||||
}
|
||||
@@ -435,7 +438,7 @@ export class ProwlarrService {
|
||||
|
||||
// Validate we have a valid download URL
|
||||
if (!downloadUrl || typeof downloadUrl !== 'string' || downloadUrl.trim() === '') {
|
||||
console.warn(`[Prowlarr] Skipping result "${result.title}" - missing both downloadUrl and magnetUrl`);
|
||||
logger.warn(` Skipping result "${result.title}" - missing both downloadUrl and magnetUrl`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -464,7 +467,7 @@ export class ProwlarrService {
|
||||
protocol: result.protocol, // 'torrent' or 'usenet'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to transform result:', result, error);
|
||||
logger.error('Failed to transform result', { title: result?.title, error: error instanceof Error ? error.message : String(error) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -513,7 +516,7 @@ export class ProwlarrService {
|
||||
|
||||
// Log detected flags for debugging
|
||||
if (flags.length > 0) {
|
||||
console.log(`[Prowlarr] ✓ Detected flags for "${result.title.substring(0, 50)}...": [${flags.join(', ')}]`);
|
||||
logger.info(` ✓ Detected flags for "${result.title.substring(0, 50)}...": [${flags.join(', ')}]`);
|
||||
}
|
||||
|
||||
return flags;
|
||||
@@ -576,7 +579,7 @@ export async function getProwlarrService(): Promise<ProwlarrService> {
|
||||
// Test connection
|
||||
const isConnected = await prowlarrService.testConnection();
|
||||
if (!isConnected) {
|
||||
console.warn('Warning: Prowlarr connection test failed');
|
||||
logger.warn('Connection test failed');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,14 @@ import axios, { AxiosInstance } from 'axios';
|
||||
import https from 'https';
|
||||
import * as parseTorrentModule from 'parse-torrent';
|
||||
import FormData from 'form-data';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
// Handle both ESM and CommonJS imports
|
||||
const parseTorrent = (parseTorrentModule as any).default || parseTorrentModule;
|
||||
|
||||
// Module-level logger
|
||||
const logger = RMABLogger.create('QBittorrent');
|
||||
|
||||
export interface AddTorrentOptions {
|
||||
savePath?: string;
|
||||
category?: string;
|
||||
@@ -104,7 +108,7 @@ export class QBittorrentService {
|
||||
this.httpsAgent = new https.Agent({
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
console.log('[qBittorrent] SSL certificate verification disabled');
|
||||
logger.info('[QBittorrent] SSL certificate verification disabled');
|
||||
}
|
||||
|
||||
this.client = axios.create({
|
||||
@@ -126,7 +130,11 @@ export class QBittorrentService {
|
||||
password: this.password,
|
||||
}),
|
||||
{
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Referer': this.baseUrl,
|
||||
'Origin': this.baseUrl,
|
||||
},
|
||||
httpsAgent: this.httpsAgent,
|
||||
}
|
||||
);
|
||||
@@ -141,9 +149,9 @@ export class QBittorrentService {
|
||||
throw new Error('Failed to authenticate with qBittorrent');
|
||||
}
|
||||
|
||||
console.log('Successfully authenticated with qBittorrent');
|
||||
logger.info('Successfully authenticated');
|
||||
} catch (error) {
|
||||
console.error('qBittorrent login failed:', error);
|
||||
logger.error('Login failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw new Error('Failed to authenticate with qBittorrent');
|
||||
}
|
||||
}
|
||||
@@ -154,7 +162,7 @@ export class QBittorrentService {
|
||||
async addTorrent(url: string, options?: AddTorrentOptions): Promise<string> {
|
||||
// Validate URL parameter
|
||||
if (!url || typeof url !== 'string' || url.trim() === '') {
|
||||
console.error('[qBittorrent] Invalid download URL:', url);
|
||||
logger.error('Invalid download URL', { url });
|
||||
throw new Error('Invalid download URL: URL is required and must be a non-empty string');
|
||||
}
|
||||
|
||||
@@ -171,21 +179,21 @@ export class QBittorrentService {
|
||||
|
||||
// Determine if this is a magnet link or .torrent file URL
|
||||
if (url.startsWith('magnet:')) {
|
||||
console.log('[qBittorrent] Detected magnet link');
|
||||
logger.info('[QBittorrent] Detected magnet link');
|
||||
return await this.addMagnetLink(url, category, options);
|
||||
} else {
|
||||
console.log('[qBittorrent] Detected .torrent file URL');
|
||||
logger.info('[QBittorrent] Detected .torrent file URL');
|
||||
return await this.addTorrentFile(url, category, options);
|
||||
}
|
||||
} catch (error) {
|
||||
// Try re-authenticating if we get a 403
|
||||
if (axios.isAxiosError(error) && error.response?.status === 403) {
|
||||
console.log('[qBittorrent] Session expired, re-authenticating...');
|
||||
logger.info('[QBittorrent] Session expired, re-authenticating...');
|
||||
await this.login();
|
||||
return this.addTorrent(url, options); // Retry once
|
||||
}
|
||||
|
||||
console.error('[qBittorrent] Failed to add torrent:', error);
|
||||
logger.error('Failed to add torrent', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw new Error('Failed to add torrent to qBittorrent');
|
||||
}
|
||||
}
|
||||
@@ -205,12 +213,12 @@ export class QBittorrentService {
|
||||
throw new Error('Invalid magnet link - could not extract info_hash');
|
||||
}
|
||||
|
||||
console.log(`[qBittorrent] Extracted info_hash from magnet: ${infoHash}`);
|
||||
logger.info(` Extracted info_hash from magnet: ${infoHash}`);
|
||||
|
||||
// Check for duplicates
|
||||
try {
|
||||
const existing = await this.getTorrent(infoHash);
|
||||
console.log(`[qBittorrent] Torrent ${infoHash} already exists (duplicate), returning existing hash`);
|
||||
logger.info(` Torrent ${infoHash} already exists (duplicate), returning existing hash`);
|
||||
return infoHash;
|
||||
} catch {
|
||||
// Torrent doesn't exist, continue with adding
|
||||
@@ -229,7 +237,7 @@ export class QBittorrentService {
|
||||
form.append('tags', options.tags.join(','));
|
||||
}
|
||||
|
||||
console.log('[qBittorrent] Uploading magnet link...');
|
||||
logger.info('[QBittorrent] Uploading magnet link...');
|
||||
|
||||
const response = await this.client.post('/torrents/add', form, {
|
||||
headers: {
|
||||
@@ -242,7 +250,7 @@ export class QBittorrentService {
|
||||
throw new Error(`qBittorrent rejected magnet link: ${response.data}`);
|
||||
}
|
||||
|
||||
console.log(`[qBittorrent] Successfully added magnet link: ${infoHash}`);
|
||||
logger.info(` Successfully added magnet link: ${infoHash}`);
|
||||
return infoHash;
|
||||
}
|
||||
|
||||
@@ -254,7 +262,7 @@ export class QBittorrentService {
|
||||
category: string,
|
||||
options?: AddTorrentOptions
|
||||
): Promise<string> {
|
||||
console.log(`[qBittorrent] Downloading .torrent file from: ${torrentUrl}`);
|
||||
logger.info(` Downloading .torrent file from: ${torrentUrl}`);
|
||||
|
||||
// Make initial request with maxRedirects: 0 to intercept redirects
|
||||
// Some Prowlarr indexers return HTTP URLs that redirect to magnet: links
|
||||
@@ -267,14 +275,14 @@ export class QBittorrentService {
|
||||
timeout: 30000, // 30 seconds - public indexers can be slow
|
||||
});
|
||||
|
||||
console.log(`[qBittorrent] Got 2xx response, size=${torrentResponse.data.length} bytes`);
|
||||
logger.info(` Got 2xx response, size=${torrentResponse.data.length} bytes`);
|
||||
|
||||
// Check if response body contains a magnet link
|
||||
if (torrentResponse.data.length > 0) {
|
||||
const responseText = torrentResponse.data.toString();
|
||||
const magnetMatch = responseText.match(/^magnet:\?[^\s]+$/);
|
||||
if (magnetMatch) {
|
||||
console.log(`[qBittorrent] Response body is a magnet link`);
|
||||
logger.info(` Response body is a magnet link`);
|
||||
return await this.addMagnetLink(magnetMatch[0], category, options);
|
||||
}
|
||||
}
|
||||
@@ -283,7 +291,7 @@ export class QBittorrentService {
|
||||
} catch (error) {
|
||||
if (!axios.isAxiosError(error) || !error.response) {
|
||||
// Not an axios error or no response - re-throw
|
||||
console.error(`[qBittorrent] Request failed:`, error);
|
||||
logger.error('Request failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -292,26 +300,26 @@ export class QBittorrentService {
|
||||
// Handle 3xx redirects
|
||||
if (status >= 300 && status < 400) {
|
||||
const location = error.response.headers['location'];
|
||||
console.log(`[qBittorrent] Got ${status} redirect to: ${location}`);
|
||||
logger.info(` Got ${status} redirect to: ${location}`);
|
||||
|
||||
// Check if redirect target is a magnet link
|
||||
if (location && location.startsWith('magnet:')) {
|
||||
console.log(`[qBittorrent] Redirect target is magnet link`);
|
||||
logger.info(` Redirect target is magnet link`);
|
||||
return await this.addMagnetLink(location, category, options);
|
||||
}
|
||||
|
||||
// Regular HTTP redirect - follow it manually
|
||||
if (location && (location.startsWith('http://') || location.startsWith('https://'))) {
|
||||
console.log(`[qBittorrent] Following HTTP redirect...`);
|
||||
logger.info(` Following HTTP redirect...`);
|
||||
try {
|
||||
torrentResponse = await axios.get(location, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 30000,
|
||||
maxRedirects: 5,
|
||||
});
|
||||
console.log(`[qBittorrent] After following redirect: size=${torrentResponse.data.length} bytes`);
|
||||
logger.info(` After following redirect: size=${torrentResponse.data.length} bytes`);
|
||||
} catch (redirectError) {
|
||||
console.error(`[qBittorrent] Failed to follow redirect:`, redirectError);
|
||||
logger.error('Failed to follow redirect', { error: redirectError instanceof Error ? redirectError.message : String(redirectError) });
|
||||
throw new Error('Failed to download torrent file after redirect');
|
||||
}
|
||||
} else {
|
||||
@@ -319,20 +327,20 @@ export class QBittorrentService {
|
||||
}
|
||||
} else {
|
||||
// Non-redirect error (4xx, 5xx)
|
||||
console.error(`[qBittorrent] HTTP error ${status}:`, error.message);
|
||||
logger.error(`HTTP error ${status}`, { error: error.message });
|
||||
throw new Error(`Failed to download torrent: HTTP ${status}`);
|
||||
}
|
||||
}
|
||||
|
||||
const torrentBuffer = Buffer.from(torrentResponse.data);
|
||||
console.log(`[qBittorrent] Processing torrent file: ${torrentBuffer.length} bytes`);
|
||||
logger.info(` Processing torrent file: ${torrentBuffer.length} bytes`);
|
||||
|
||||
// Parse .torrent file to extract info_hash (deterministic)
|
||||
let parsedTorrent: any;
|
||||
try {
|
||||
parsedTorrent = await parseTorrent(torrentBuffer);
|
||||
} catch (error) {
|
||||
console.error('[qBittorrent] Failed to parse .torrent file:', error);
|
||||
logger.error('Failed to parse .torrent file', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw new Error('Invalid .torrent file - failed to parse');
|
||||
}
|
||||
|
||||
@@ -342,13 +350,13 @@ export class QBittorrentService {
|
||||
throw new Error('Failed to extract info_hash from .torrent file');
|
||||
}
|
||||
|
||||
console.log(`[qBittorrent] Extracted info_hash: ${infoHash}`);
|
||||
console.log(`[qBittorrent] Torrent name: ${parsedTorrent.name || 'Unknown'}`);
|
||||
logger.info(` Extracted info_hash: ${infoHash}`);
|
||||
logger.info(` Torrent name: ${parsedTorrent.name || 'Unknown'}`);
|
||||
|
||||
// Check for duplicates
|
||||
try {
|
||||
const existing = await this.getTorrent(infoHash);
|
||||
console.log(`[qBittorrent] Torrent ${infoHash} already exists (duplicate), returning existing hash`);
|
||||
logger.info(` Torrent ${infoHash} already exists (duplicate), returning existing hash`);
|
||||
return infoHash;
|
||||
} catch {
|
||||
// Torrent doesn't exist, continue with adding
|
||||
@@ -371,7 +379,7 @@ export class QBittorrentService {
|
||||
formData.append('tags', options.tags.join(','));
|
||||
}
|
||||
|
||||
console.log('[qBittorrent] Uploading .torrent file content...');
|
||||
logger.info('[QBittorrent] Uploading .torrent file content...');
|
||||
|
||||
const response = await this.client.post('/torrents/add', formData, {
|
||||
headers: {
|
||||
@@ -386,7 +394,7 @@ export class QBittorrentService {
|
||||
throw new Error(`qBittorrent rejected .torrent file: ${response.data}`);
|
||||
}
|
||||
|
||||
console.log(`[qBittorrent] Successfully added torrent: ${infoHash}`);
|
||||
logger.info(` Successfully added torrent: ${infoHash}`);
|
||||
return infoHash;
|
||||
}
|
||||
|
||||
@@ -410,7 +418,7 @@ export class QBittorrentService {
|
||||
|
||||
if (!existingCategory) {
|
||||
// Category doesn't exist - create it
|
||||
console.log(`[qBittorrent] Creating category "${category}" with save path: ${this.defaultSavePath}`);
|
||||
logger.info(` Creating category "${category}" with save path: ${this.defaultSavePath}`);
|
||||
|
||||
await this.client.post(
|
||||
'/torrents/createCategory',
|
||||
@@ -426,13 +434,13 @@ export class QBittorrentService {
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`[qBittorrent] Category "${category}" created successfully`);
|
||||
logger.info(` Category "${category}" created successfully`);
|
||||
} else {
|
||||
// Category exists - check if save path needs updating
|
||||
const currentSavePath = existingCategory.savePath || existingCategory.save_path;
|
||||
|
||||
if (currentSavePath !== this.defaultSavePath) {
|
||||
console.log(`[qBittorrent] Updating category "${category}" save path from "${currentSavePath}" to "${this.defaultSavePath}"`);
|
||||
logger.info(` Updating category "${category}" save path from "${currentSavePath}" to "${this.defaultSavePath}"`);
|
||||
|
||||
await this.client.post(
|
||||
'/torrents/editCategory',
|
||||
@@ -448,23 +456,23 @@ export class QBittorrentService {
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`[qBittorrent] Category "${category}" save path updated successfully`);
|
||||
logger.info(` Category "${category}" save path updated successfully`);
|
||||
} else {
|
||||
console.log(`[qBittorrent] Category "${category}" already has correct save path: ${this.defaultSavePath}`);
|
||||
logger.info(` Category "${category}" already has correct save path: ${this.defaultSavePath}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// If we can't ensure the category, log error but don't throw
|
||||
// Torrents can still be added with per-torrent savepath parameter
|
||||
if (axios.isAxiosError(error)) {
|
||||
console.error(`[qBittorrent] Failed to ensure category "${category}":`, {
|
||||
logger.error(` Failed to ensure category "${category}":`, {
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
data: error.response?.data,
|
||||
requestedPath: this.defaultSavePath,
|
||||
});
|
||||
} else {
|
||||
console.error(`[qBittorrent] Failed to ensure category:`, error);
|
||||
logger.error('Failed to ensure category', { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -516,7 +524,7 @@ export class QBittorrentService {
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to get torrents:', error);
|
||||
logger.error('Failed to get torrents', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw new Error('Failed to get torrents from qBittorrent');
|
||||
}
|
||||
}
|
||||
@@ -541,9 +549,9 @@ export class QBittorrentService {
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`Paused torrent: ${hash}`);
|
||||
logger.info(`Paused torrent: ${hash}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to pause torrent:', error);
|
||||
logger.error('Failed to pause torrent', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw new Error('Failed to pause torrent');
|
||||
}
|
||||
}
|
||||
@@ -568,9 +576,9 @@ export class QBittorrentService {
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`Resumed torrent: ${hash}`);
|
||||
logger.info(`Resumed torrent: ${hash}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to resume torrent:', error);
|
||||
logger.error('Failed to resume torrent', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw new Error('Failed to resume torrent');
|
||||
}
|
||||
}
|
||||
@@ -598,9 +606,9 @@ export class QBittorrentService {
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`Deleted torrent: ${hash}`);
|
||||
logger.info(`Deleted torrent: ${hash}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete torrent:', error);
|
||||
logger.error('Failed to delete torrent', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw new Error('Failed to delete torrent');
|
||||
}
|
||||
}
|
||||
@@ -621,7 +629,7 @@ export class QBittorrentService {
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to get torrent files:', error);
|
||||
logger.error('Failed to get torrent files', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw new Error('Failed to get torrent files');
|
||||
}
|
||||
}
|
||||
@@ -649,9 +657,9 @@ export class QBittorrentService {
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`Set category for torrent ${hash}: ${category}`);
|
||||
logger.info(`Set category for torrent ${hash}: ${category}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to set category:', error);
|
||||
logger.error('Failed to set category', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw new Error('Failed to set torrent category');
|
||||
}
|
||||
}
|
||||
@@ -664,7 +672,7 @@ export class QBittorrentService {
|
||||
await this.login();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('qBittorrent connection test failed:', error);
|
||||
logger.error('Connection test failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -686,7 +694,7 @@ export class QBittorrentService {
|
||||
httpsAgent = new https.Agent({
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
console.log('[qBittorrent] SSL certificate verification disabled for test connection');
|
||||
logger.info('[QBittorrent] SSL certificate verification disabled for test connection');
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -694,7 +702,11 @@ export class QBittorrentService {
|
||||
`${baseUrl}/api/v2/auth/login`,
|
||||
new URLSearchParams({ username, password }),
|
||||
{
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Referer': baseUrl,
|
||||
'Origin': baseUrl,
|
||||
},
|
||||
httpsAgent,
|
||||
}
|
||||
);
|
||||
@@ -714,7 +726,7 @@ export class QBittorrentService {
|
||||
|
||||
return versionResponse.data || 'Connected';
|
||||
} catch (error) {
|
||||
console.error('[qBittorrent] Connection test failed:', error);
|
||||
logger.error('Connection test failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
|
||||
// Enhanced error messages for common issues
|
||||
if (axios.isAxiosError(error)) {
|
||||
@@ -856,7 +868,7 @@ let configLoaded = false;
|
||||
* Forces service to reload configuration from database on next use
|
||||
*/
|
||||
export function invalidateQBittorrentService(): void {
|
||||
console.log('[qBittorrent] Invalidating service singleton - will reload config on next use');
|
||||
logger.info('[QBittorrent] Invalidating service singleton - will reload config on next use');
|
||||
qbittorrentService = null;
|
||||
configLoaded = false;
|
||||
}
|
||||
@@ -869,7 +881,7 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
|
||||
const { getConfigService } = await import('@/lib/services/config.service');
|
||||
const configService = getConfigService();
|
||||
|
||||
console.log('[qBittorrent] Loading configuration from database...');
|
||||
logger.info('[QBittorrent] Loading configuration from database...');
|
||||
const config = await configService.getMany([
|
||||
'download_client_url',
|
||||
'download_client_username',
|
||||
@@ -878,7 +890,7 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
|
||||
'download_client_disable_ssl_verify',
|
||||
]);
|
||||
|
||||
console.log('[qBittorrent] Config loaded:', {
|
||||
logger.info('[QBittorrent] Config loaded:', {
|
||||
hasUrl: !!config.download_client_url,
|
||||
hasUsername: !!config.download_client_username,
|
||||
hasPassword: !!config.download_client_password,
|
||||
@@ -904,7 +916,7 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
const errorMsg = `qBittorrent is not fully configured. Missing: ${missingFields.join(', ')}. Please configure qBittorrent in the admin settings.`;
|
||||
console.error('[qBittorrent]', errorMsg);
|
||||
logger.error('Configuration incomplete', { missingFields });
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
@@ -915,7 +927,7 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
|
||||
const savePath = config.download_dir as string;
|
||||
const disableSSLVerify = config.download_client_disable_ssl_verify === 'true';
|
||||
|
||||
console.log('[qBittorrent] Creating service instance...');
|
||||
logger.info('[QBittorrent] Creating service instance...');
|
||||
qbittorrentService = new QBittorrentService(
|
||||
url,
|
||||
username,
|
||||
@@ -926,17 +938,17 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
|
||||
);
|
||||
|
||||
// Test connection
|
||||
console.log('[qBittorrent] Testing connection...');
|
||||
logger.info('[QBittorrent] Testing connection...');
|
||||
const isConnected = await qbittorrentService.testConnection();
|
||||
if (!isConnected) {
|
||||
console.warn('[qBittorrent] Connection test failed');
|
||||
logger.warn('[QBittorrent] Connection test failed');
|
||||
throw new Error('qBittorrent connection test failed. Please check your configuration in admin settings.');
|
||||
} else {
|
||||
console.log('[qBittorrent] Connection test successful');
|
||||
logger.info('[QBittorrent] Connection test successful');
|
||||
configLoaded = true; // Mark as successfully loaded
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[qBittorrent] Failed to initialize service:', error);
|
||||
logger.error('Failed to initialize service', { error: error instanceof Error ? error.message : String(error) });
|
||||
qbittorrentService = null; // Reset service on error
|
||||
configLoaded = false;
|
||||
throw error;
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import https from 'https';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('SABnzbd');
|
||||
|
||||
export interface AddNZBOptions {
|
||||
category?: string;
|
||||
@@ -238,7 +241,7 @@ export class SABnzbdService {
|
||||
const categoryExists = config.categories.some(cat => cat.name === this.defaultCategory);
|
||||
|
||||
if (!categoryExists) {
|
||||
console.log(`[SABnzbd] Creating category: ${this.defaultCategory}`);
|
||||
logger.info(`Creating category: ${this.defaultCategory}`);
|
||||
|
||||
// Create category
|
||||
await this.client.get('/api', {
|
||||
@@ -252,12 +255,12 @@ export class SABnzbdService {
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[SABnzbd] Category created successfully: ${this.defaultCategory}`);
|
||||
logger.info(`Category created successfully: ${this.defaultCategory}`);
|
||||
} else {
|
||||
console.log(`[SABnzbd] Category already exists: ${this.defaultCategory}`);
|
||||
logger.info(`Category already exists: ${this.defaultCategory}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SABnzbd] Failed to ensure category:', error);
|
||||
logger.error('Failed to ensure category', { error: error instanceof Error ? error.message : String(error) });
|
||||
// Don't throw - category creation failure shouldn't block downloads
|
||||
}
|
||||
}
|
||||
@@ -267,7 +270,7 @@ export class SABnzbdService {
|
||||
* Returns the NZB ID
|
||||
*/
|
||||
async addNZB(url: string, options?: AddNZBOptions): Promise<string> {
|
||||
console.log(`[SABnzbd] Adding NZB from URL: ${url.substring(0, 150)}...`);
|
||||
logger.info(`Adding NZB from URL: ${url.substring(0, 150)}...`);
|
||||
|
||||
const response = await this.client.get('/api', {
|
||||
params: {
|
||||
@@ -291,7 +294,7 @@ export class SABnzbdService {
|
||||
}
|
||||
|
||||
const nzbId = nzbIds[0];
|
||||
console.log(`[SABnzbd] Added NZB: ${nzbId}`);
|
||||
logger.info(`Added NZB: ${nzbId}`);
|
||||
|
||||
return nzbId;
|
||||
}
|
||||
@@ -559,5 +562,5 @@ export async function getSABnzbdService(): Promise<SABnzbdService> {
|
||||
|
||||
export function invalidateSABnzbdService(): void {
|
||||
sabnzbdServiceInstance = null;
|
||||
console.log('[SABnzbd] Service singleton invalidated');
|
||||
logger.info('Service singleton invalidated');
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifyAccessToken, TokenPayload } from '../utils/jwt';
|
||||
import { prisma } from '../db';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('Auth');
|
||||
|
||||
export interface AuthenticatedRequest extends NextRequest {
|
||||
user?: TokenPayload & { id: string };
|
||||
@@ -40,7 +43,7 @@ export async function requireAuth(
|
||||
const token = extractToken(request);
|
||||
|
||||
if (!token) {
|
||||
console.error('[Auth Middleware] No token provided');
|
||||
logger.error('No token provided');
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Unauthorized',
|
||||
@@ -53,7 +56,7 @@ export async function requireAuth(
|
||||
const payload = verifyAccessToken(token);
|
||||
|
||||
if (!payload) {
|
||||
console.error('[Auth Middleware] Token verification failed');
|
||||
logger.error('Token verification failed');
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Unauthorized',
|
||||
@@ -69,7 +72,7 @@ export async function requireAuth(
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
console.error('[Auth Middleware] User not found in database:', payload.sub);
|
||||
logger.error('User not found in database', { userId: payload.sub });
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Unauthorized',
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { prisma } from '../db';
|
||||
import { createJobLogger } from '../utils/job-logger';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
export interface AudibleRefreshPayload {
|
||||
jobId?: string;
|
||||
@@ -15,9 +15,9 @@ export interface AudibleRefreshPayload {
|
||||
|
||||
export async function processAudibleRefresh(payload: AudibleRefreshPayload): Promise<any> {
|
||||
const { jobId, scheduledJobId } = payload;
|
||||
const logger = jobId ? createJobLogger(jobId, 'AudibleRefresh') : null;
|
||||
const logger = RMABLogger.forJob(jobId, 'AudibleRefresh');
|
||||
|
||||
await logger?.info('Starting Audible data refresh...');
|
||||
logger.info('Starting Audible data refresh...');
|
||||
|
||||
const { getAudibleService } = await import('../integrations/audible.service');
|
||||
const { getThumbnailCacheService } = await import('../services/thumbnail-cache.service');
|
||||
@@ -40,13 +40,13 @@ export async function processAudibleRefresh(payload: AudibleRefreshPayload): Pro
|
||||
newReleaseRank: null,
|
||||
},
|
||||
});
|
||||
await logger?.info('Cleared previous popular/new-release flags in audible_cache');
|
||||
logger.info('Cleared previous popular/new-release flags in audible_cache');
|
||||
|
||||
// Fetch popular and new releases - 200 items each
|
||||
const popular = await audibleService.getPopularAudiobooks(200);
|
||||
const newReleases = await audibleService.getNewReleases(200);
|
||||
|
||||
await logger?.info(`Fetched ${popular.length} popular, ${newReleases.length} new releases from Audible`);
|
||||
logger.info(`Fetched ${popular.length} popular, ${newReleases.length} new releases from Audible`);
|
||||
|
||||
// Persist to audible_cache
|
||||
let popularSaved = 0;
|
||||
@@ -99,7 +99,7 @@ export async function processAudibleRefresh(payload: AudibleRefreshPayload): Pro
|
||||
|
||||
popularSaved++;
|
||||
} catch (error) {
|
||||
await logger?.error(`Failed to save popular audiobook ${audiobook.title}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
logger.error(`Failed to save popular audiobook ${audiobook.title}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,20 +149,20 @@ export async function processAudibleRefresh(payload: AudibleRefreshPayload): Pro
|
||||
|
||||
newReleasesSaved++;
|
||||
} catch (error) {
|
||||
await logger?.error(`Failed to save new release ${audiobook.title}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
logger.error(`Failed to save new release ${audiobook.title}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
await logger?.info(`Saved ${popularSaved} popular and ${newReleasesSaved} new releases to audible_cache`);
|
||||
logger.info(`Saved ${popularSaved} popular and ${newReleasesSaved} new releases to audible_cache`);
|
||||
|
||||
// Cleanup unused thumbnails
|
||||
await logger?.info('Cleaning up unused thumbnails...');
|
||||
logger.info('Cleaning up unused thumbnails...');
|
||||
const allActiveAsins = await prisma.audibleCache.findMany({
|
||||
select: { asin: true },
|
||||
});
|
||||
const activeAsinSet = new Set(allActiveAsins.map(item => item.asin));
|
||||
const deletedCount = await thumbnailCache.cleanupUnusedThumbnails(activeAsinSet);
|
||||
await logger?.info(`Cleanup complete: ${deletedCount} unused thumbnails removed`);
|
||||
logger.info(`Cleanup complete: ${deletedCount} unused thumbnails removed`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -172,7 +172,7 @@ export async function processAudibleRefresh(payload: AudibleRefreshPayload): Pro
|
||||
thumbnailsDeleted: deletedCount,
|
||||
};
|
||||
} catch (error) {
|
||||
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { prisma } from '../db';
|
||||
import { createJobLogger } from '../utils/job-logger';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
export interface CleanupSeededTorrentsPayload {
|
||||
jobId?: string;
|
||||
@@ -15,9 +15,9 @@ export interface CleanupSeededTorrentsPayload {
|
||||
|
||||
export async function processCleanupSeededTorrents(payload: CleanupSeededTorrentsPayload): Promise<any> {
|
||||
const { jobId, scheduledJobId } = payload;
|
||||
const logger = jobId ? createJobLogger(jobId, 'CleanupSeededTorrents') : null;
|
||||
const logger = RMABLogger.forJob(jobId, 'CleanupSeededTorrents');
|
||||
|
||||
await logger?.info('Starting cleanup job for seeded torrents...');
|
||||
logger.info('Starting cleanup job for seeded torrents...');
|
||||
|
||||
try {
|
||||
// Get indexer configuration with per-indexer seeding times
|
||||
@@ -26,7 +26,7 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
const indexersConfigStr = await configService.get('prowlarr_indexers');
|
||||
|
||||
if (!indexersConfigStr) {
|
||||
await logger?.warn('No indexer configuration found, skipping');
|
||||
logger.warn('No indexer configuration found, skipping');
|
||||
return {
|
||||
success: false,
|
||||
message: 'No indexer configuration',
|
||||
@@ -42,7 +42,7 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
indexerConfigMap.set(indexer.name, indexer);
|
||||
}
|
||||
|
||||
await logger?.info(`Loaded configuration for ${indexerConfigMap.size} indexers`);
|
||||
logger.info(`Loaded configuration for ${indexerConfigMap.size} indexers`);
|
||||
|
||||
// Find all completed requests + soft-deleted requests (orphaned downloads)
|
||||
// IMPORTANT: Only cleanup requests that are truly complete and not being actively processed
|
||||
@@ -76,7 +76,7 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
take: 100, // Limit to 100 requests per run
|
||||
});
|
||||
|
||||
await logger?.info(`Found ${completedRequests.length} requests to check (status: 'available' or soft-deleted)`);
|
||||
logger.info(`Found ${completedRequests.length} requests to check (status: 'available' or soft-deleted)`);
|
||||
|
||||
let cleaned = 0;
|
||||
let skipped = 0;
|
||||
@@ -95,7 +95,7 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
// For soft-deleted SABnzbd requests, hard delete immediately (no seeding needed)
|
||||
if (request.deletedAt) {
|
||||
await prisma.request.delete({ where: { id: request.id } });
|
||||
await logger?.info(`Hard-deleted orphaned SABnzbd request ${request.id}`);
|
||||
logger.info(`Hard-deleted orphaned SABnzbd request ${request.id}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -116,7 +116,7 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
// For soft-deleted requests with unlimited seeding, hard delete immediately
|
||||
if (request.deletedAt) {
|
||||
await prisma.request.delete({ where: { id: request.id } });
|
||||
await logger?.info(`Hard-deleted orphaned request ${request.id} with unlimited seeding`);
|
||||
logger.info(`Hard-deleted orphaned request ${request.id} with unlimited seeding`);
|
||||
}
|
||||
noConfig++;
|
||||
continue;
|
||||
@@ -146,7 +146,7 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
continue;
|
||||
}
|
||||
|
||||
await logger?.info(`Torrent ${torrent.name} (${indexerName}) has met seeding requirement (${Math.floor(actualSeedingTime / 60)}/${seedingConfig.seedingTimeMinutes} minutes)`);
|
||||
logger.info(`Torrent ${torrent.name} (${indexerName}) has met seeding requirement (${Math.floor(actualSeedingTime / 60)}/${seedingConfig.seedingTimeMinutes} minutes)`);
|
||||
|
||||
// CRITICAL: Check if any other active (non-deleted) request is using this same torrent hash
|
||||
// This prevents deleting shared torrents when user re-requests the same audiobook
|
||||
@@ -165,12 +165,12 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
});
|
||||
|
||||
if (otherActiveRequests.length > 0) {
|
||||
await logger?.info(`Skipping torrent deletion - ${otherActiveRequests.length} other active request(s) still using this torrent (IDs: ${otherActiveRequests.map(r => r.id).join(', ')})`);
|
||||
logger.info(`Skipping torrent deletion - ${otherActiveRequests.length} other active request(s) still using this torrent (IDs: ${otherActiveRequests.map(r => r.id).join(', ')})`);
|
||||
|
||||
// If this is a soft-deleted request, hard delete it but DON'T delete the torrent
|
||||
if (request.deletedAt) {
|
||||
await prisma.request.delete({ where: { id: request.id } });
|
||||
await logger?.info(`Hard-deleted orphaned request ${request.id} (kept shared torrent for active requests)`);
|
||||
logger.info(`Hard-deleted orphaned request ${request.id} (kept shared torrent for active requests)`);
|
||||
}
|
||||
|
||||
skipped++;
|
||||
@@ -183,18 +183,18 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
// If this is a soft-deleted request (orphaned download), hard delete it now
|
||||
if (request.deletedAt) {
|
||||
await prisma.request.delete({ where: { id: request.id } });
|
||||
await logger?.info(`Hard-deleted orphaned request ${request.id} after torrent cleanup`);
|
||||
logger.info(`Hard-deleted orphaned request ${request.id} after torrent cleanup`);
|
||||
} else {
|
||||
await logger?.info(`Deleted torrent and files for active request ${request.id}`);
|
||||
logger.info(`Deleted torrent and files for active request ${request.id}`);
|
||||
}
|
||||
|
||||
cleaned++;
|
||||
} catch (error) {
|
||||
await logger?.error(`Failed to cleanup request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
logger.error(`Failed to cleanup request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
await logger?.info(`Cleanup complete: ${cleaned} torrents cleaned, ${skipped} still seeding, ${noConfig} unlimited`);
|
||||
logger.info(`Cleanup complete: ${cleaned} torrents cleaned, ${skipped} still seeding, ${noConfig} unlimited`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -205,7 +205,7 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
unlimited: noConfig,
|
||||
};
|
||||
} catch (error) {
|
||||
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { prisma } from '../db';
|
||||
import { getQBittorrentService } from '../integrations/qbittorrent.service';
|
||||
import { getSABnzbdService } from '../integrations/sabnzbd.service';
|
||||
import { getConfigService } from '../services/config.service';
|
||||
import { createJobLogger } from '../utils/job-logger';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* Process download job
|
||||
@@ -18,10 +18,10 @@ import { createJobLogger } from '../utils/job-logger';
|
||||
export async function processDownloadTorrent(payload: DownloadTorrentPayload): Promise<any> {
|
||||
const { requestId, audiobook, torrent, jobId } = payload;
|
||||
|
||||
const logger = jobId ? createJobLogger(jobId, 'DownloadTorrent') : null;
|
||||
const logger = RMABLogger.forJob(jobId, 'DownloadTorrent');
|
||||
|
||||
await logger?.info(`Processing request ${requestId} for "${audiobook.title}"`);
|
||||
await logger?.info(`Selected result: ${torrent.title}`, {
|
||||
logger.info(`Processing request ${requestId} for "${audiobook.title}"`);
|
||||
logger.info(`Selected result: ${torrent.title}`, {
|
||||
size: torrent.size,
|
||||
seeders: torrent.seeders,
|
||||
format: torrent.format,
|
||||
@@ -48,7 +48,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
||||
|
||||
if (clientType === 'sabnzbd') {
|
||||
// Route to SABnzbd
|
||||
await logger?.info(`Routing to SABnzbd`);
|
||||
logger.info(`Routing to SABnzbd`);
|
||||
|
||||
const sabnzbd = await getSABnzbdService();
|
||||
downloadClientId = await sabnzbd.addNZB(torrent.downloadUrl, {
|
||||
@@ -57,7 +57,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
||||
});
|
||||
downloadClient = 'sabnzbd';
|
||||
|
||||
await logger?.info(`NZB added with ID: ${downloadClientId}`);
|
||||
logger.info(`NZB added with ID: ${downloadClientId}`);
|
||||
|
||||
// Create DownloadHistory record
|
||||
const downloadHistory = await prisma.downloadHistory.create({
|
||||
@@ -79,7 +79,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
||||
},
|
||||
});
|
||||
|
||||
await logger?.info(`Created download history record: ${downloadHistory.id}`);
|
||||
logger.info(`Created download history record: ${downloadHistory.id}`);
|
||||
|
||||
// Trigger monitor download job with initial delay
|
||||
const jobQueue = getJobQueueService();
|
||||
@@ -91,7 +91,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
||||
3 // Wait 3 seconds before first check
|
||||
);
|
||||
|
||||
await logger?.info(`Started monitoring job for request ${requestId} (SABnzbd, 3s initial delay)`);
|
||||
logger.info(`Started monitoring job for request ${requestId} (SABnzbd, 3s initial delay)`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -107,7 +107,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
||||
};
|
||||
} else {
|
||||
// Route to qBittorrent (default)
|
||||
await logger?.info(`Routing to qBittorrent`);
|
||||
logger.info(`Routing to qBittorrent`);
|
||||
|
||||
const qbt = await getQBittorrentService();
|
||||
downloadClientId = await qbt.addTorrent(torrent.downloadUrl, {
|
||||
@@ -118,7 +118,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
||||
});
|
||||
downloadClient = 'qbittorrent';
|
||||
|
||||
await logger?.info(`Torrent added with hash: ${downloadClientId}`);
|
||||
logger.info(`Torrent added with hash: ${downloadClientId}`);
|
||||
|
||||
// Create DownloadHistory record
|
||||
const downloadHistory = await prisma.downloadHistory.create({
|
||||
@@ -140,7 +140,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
||||
},
|
||||
});
|
||||
|
||||
await logger?.info(`Created download history record: ${downloadHistory.id}`);
|
||||
logger.info(`Created download history record: ${downloadHistory.id}`);
|
||||
|
||||
// Trigger monitor download job with initial delay
|
||||
const jobQueue = getJobQueueService();
|
||||
@@ -152,7 +152,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
||||
3 // Wait 3 seconds before first check to avoid race condition
|
||||
);
|
||||
|
||||
await logger?.info(`Started monitoring job for request ${requestId} (qBittorrent, 3s initial delay)`);
|
||||
logger.info(`Started monitoring job for request ${requestId} (qBittorrent, 3s initial delay)`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -169,7 +169,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
|
||||
// Update request status to failed
|
||||
await prisma.request.update({
|
||||
|
||||
@@ -11,7 +11,7 @@ import { prisma } from '../db';
|
||||
import { getLibraryService } from '../services/library';
|
||||
import { compareTwoStrings } from 'string-similarity';
|
||||
import { getConfigService } from '../services/config.service';
|
||||
import { createJobLogger } from '../utils/job-logger';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* Process match library job (DEPRECATED - use scan_library instead)
|
||||
@@ -20,10 +20,10 @@ import { createJobLogger } from '../utils/job-logger';
|
||||
export async function processMatchPlex(payload: MatchPlexPayload): Promise<any> {
|
||||
const { requestId, audiobookId, title, author, jobId } = payload;
|
||||
|
||||
const logger = jobId ? createJobLogger(jobId, 'MatchLibrary') : null;
|
||||
const logger = RMABLogger.forJob(jobId, 'MatchLibrary');
|
||||
|
||||
await logger?.warn('DEPRECATED: match_plex job is deprecated. Use scan_plex instead.');
|
||||
await logger?.info(`Matching "${title}" by ${author} in library`);
|
||||
logger.warn('DEPRECATED: match_plex job is deprecated. Use scan_plex instead.');
|
||||
logger.info(`Matching "${title}" by ${author} in library`);
|
||||
|
||||
try {
|
||||
// Get library service and configuration
|
||||
@@ -31,7 +31,7 @@ export async function processMatchPlex(payload: MatchPlexPayload): Promise<any>
|
||||
const libraryService = await getLibraryService();
|
||||
const backendMode = await configService.getBackendMode();
|
||||
|
||||
await logger?.info(`Backend mode: ${backendMode}`);
|
||||
logger.info(`Backend mode: ${backendMode}`);
|
||||
|
||||
// Get configured library ID
|
||||
const libraryId = backendMode === 'audiobookshelf'
|
||||
@@ -45,10 +45,10 @@ export async function processMatchPlex(payload: MatchPlexPayload): Promise<any>
|
||||
// Search library using abstraction layer
|
||||
const searchResults = await libraryService.searchItems(libraryId, title);
|
||||
|
||||
await logger?.info(`Found ${searchResults.length} results in library`);
|
||||
logger.info(`Found ${searchResults.length} results in library`);
|
||||
|
||||
if (searchResults.length === 0) {
|
||||
await logger?.warn(`No matches found in library for "${title}"`);
|
||||
logger.warn(`No matches found in library for "${title}"`);
|
||||
|
||||
// Mark as completed anyway - the file is there, library just needs time to scan
|
||||
await prisma.request.update({
|
||||
@@ -92,7 +92,7 @@ export async function processMatchPlex(payload: MatchPlexPayload): Promise<any>
|
||||
|
||||
const bestMatch = matches[0];
|
||||
|
||||
await logger?.info(`Best match: "${bestMatch.item.title}" by ${bestMatch.item.author || 'Unknown'}`, {
|
||||
logger.info(`Best match: "${bestMatch.item.title}" by ${bestMatch.item.author || 'Unknown'}`, {
|
||||
score: Math.round(bestMatch.score * 100),
|
||||
titleScore: Math.round(bestMatch.titleScore * 100),
|
||||
authorScore: Math.round(bestMatch.authorScore * 100),
|
||||
@@ -100,7 +100,7 @@ export async function processMatchPlex(payload: MatchPlexPayload): Promise<any>
|
||||
|
||||
// Accept match if score >= 70%
|
||||
if (bestMatch.score >= 0.7) {
|
||||
await logger?.info(`Match accepted!`);
|
||||
logger.info(`Match accepted!`);
|
||||
|
||||
// Update audiobook with library item ID
|
||||
const updateData: any = {
|
||||
@@ -144,7 +144,7 @@ export async function processMatchPlex(payload: MatchPlexPayload): Promise<any>
|
||||
},
|
||||
};
|
||||
} else {
|
||||
await logger?.warn(`Match score too low (${Math.round(bestMatch.score * 100)}%), but marking as completed anyway`);
|
||||
logger.warn(`Match score too low (${Math.round(bestMatch.score * 100)}%), but marking as completed anyway`);
|
||||
|
||||
// Mark as completed even if match is poor
|
||||
await prisma.request.update({
|
||||
@@ -166,7 +166,7 @@ export async function processMatchPlex(payload: MatchPlexPayload): Promise<any>
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
|
||||
// Don't fail the request - the files are organized correctly
|
||||
// Just log the error and mark as completed
|
||||
|
||||
@@ -7,7 +7,7 @@ import path from 'path';
|
||||
import { MonitorDownloadPayload, getJobQueueService } from '../services/job-queue.service';
|
||||
import { prisma } from '../db';
|
||||
import { getQBittorrentService } from '../integrations/qbittorrent.service';
|
||||
import { createJobLogger, JobLogger } from '../utils/job-logger';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { PathMapper } from '../utils/path-mapper';
|
||||
import { getConfigService } from '../services/config.service';
|
||||
|
||||
@@ -18,7 +18,7 @@ import { getConfigService } from '../services/config.service';
|
||||
async function getTorrentWithRetry(
|
||||
qbt: any,
|
||||
hash: string,
|
||||
logger: JobLogger | null,
|
||||
logger: RMABLogger,
|
||||
maxRetries: number = 3,
|
||||
initialDelayMs: number = 500
|
||||
): Promise<any> {
|
||||
@@ -37,7 +37,7 @@ async function getTorrentWithRetry(
|
||||
|
||||
// Exponential backoff: 500ms, 1000ms, 2000ms
|
||||
const delayMs = initialDelayMs * Math.pow(2, attempt);
|
||||
await logger?.warn(`Torrent ${hash} not found, retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`);
|
||||
logger.warn(`Torrent ${hash} not found, retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
@@ -55,7 +55,7 @@ async function getTorrentWithRetry(
|
||||
export async function processMonitorDownload(payload: MonitorDownloadPayload): Promise<any> {
|
||||
const { requestId, downloadHistoryId, downloadClientId, downloadClient, jobId } = payload;
|
||||
|
||||
const logger = jobId ? createJobLogger(jobId, 'MonitorDownload') : null;
|
||||
const logger = RMABLogger.forJob(jobId, 'MonitorDownload');
|
||||
|
||||
try {
|
||||
let progress: any;
|
||||
@@ -96,7 +96,7 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
// Store download path if available (only set after completion)
|
||||
downloadPath = nzbInfo.downloadPath;
|
||||
|
||||
await logger?.info(`SABnzbd status: ${nzbInfo.status}`, {
|
||||
logger.info(`SABnzbd status: ${nzbInfo.status}`, {
|
||||
progress: `${(nzbInfo.progress * 100).toFixed(1)}%`,
|
||||
speed: `${(nzbInfo.downloadSpeed / 1024 / 1024).toFixed(2)} MB/s`,
|
||||
});
|
||||
@@ -123,7 +123,7 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
|
||||
// Check download state
|
||||
if (progress.state === 'completed') {
|
||||
await logger?.info(`Download completed for request ${requestId}`);
|
||||
logger.info(`Download completed for request ${requestId}`);
|
||||
|
||||
// Ensure we have a download path
|
||||
if (!downloadPath) {
|
||||
@@ -145,7 +145,7 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
localPath: pathMappingConfig.download_client_local_path || '',
|
||||
});
|
||||
|
||||
await logger?.info(`Download completed`, {
|
||||
logger.info(`Download completed`, {
|
||||
downloadClient,
|
||||
downloadPath,
|
||||
organizePath: organizePath !== downloadPath ? `${organizePath} (mapped)` : organizePath,
|
||||
@@ -183,7 +183,7 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
organizePath
|
||||
);
|
||||
|
||||
await logger?.info(`Triggered organize_files job for request ${requestId}`);
|
||||
logger.info(`Triggered organize_files job for request ${requestId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -194,7 +194,7 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
downloadPath: organizePath,
|
||||
};
|
||||
} else if (progress.state === 'failed') {
|
||||
await logger?.error(`Download failed for request ${requestId}`);
|
||||
logger.error(`Download failed for request ${requestId}`);
|
||||
|
||||
// Update request to failed
|
||||
await prisma.request.update({
|
||||
@@ -236,7 +236,7 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
// Only log every 5% progress to reduce log spam
|
||||
const shouldLog = progress.percent % 5 === 0 || progress.percent < 5;
|
||||
if (shouldLog) {
|
||||
await logger?.info(`Request ${requestId}: ${progress.percent}% complete (${progress.state})`, {
|
||||
logger.info(`Request ${requestId}: ${progress.percent}% complete (${progress.state})`, {
|
||||
speed: progress.speed,
|
||||
eta: progress.eta,
|
||||
});
|
||||
@@ -254,7 +254,7 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
|
||||
// Check if this is a transient "torrent not found" error
|
||||
const errorMessage = error instanceof Error ? error.message : '';
|
||||
@@ -263,7 +263,7 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
if (isTorrentNotFound) {
|
||||
// Transient error - don't mark request as failed, let Bull retry
|
||||
// The request stays in 'downloading' status until Bull exhausts all retries
|
||||
await logger?.warn(`Transient error for request ${requestId}, allowing Bull to retry`);
|
||||
logger.warn(`Transient error for request ${requestId}, allowing Bull to retry`);
|
||||
} else {
|
||||
// Permanent error - mark request as failed immediately
|
||||
await prisma.request.update({
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { prisma } from '../db';
|
||||
import { createJobLogger } from '../utils/job-logger';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { getJobQueueService } from '../services/job-queue.service';
|
||||
|
||||
export interface MonitorRssFeedsPayload {
|
||||
@@ -16,9 +16,9 @@ export interface MonitorRssFeedsPayload {
|
||||
|
||||
export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): Promise<any> {
|
||||
const { jobId, scheduledJobId } = payload;
|
||||
const logger = jobId ? createJobLogger(jobId, 'MonitorRssFeeds') : null;
|
||||
const logger = RMABLogger.forJob(jobId, 'MonitorRssFeeds');
|
||||
|
||||
await logger?.info(`Starting RSS feed monitoring...`);
|
||||
logger.info(`Starting RSS feed monitoring...`);
|
||||
|
||||
// Get indexer configuration
|
||||
const { getConfigService } = await import('../services/config.service');
|
||||
@@ -26,7 +26,7 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P
|
||||
const indexersConfigStr = await configService.get('prowlarr_indexers');
|
||||
|
||||
if (!indexersConfigStr) {
|
||||
await logger?.warn(`No indexers configured, skipping`);
|
||||
logger.warn(`No indexers configured, skipping`);
|
||||
return { success: false, message: 'No indexers configured', skipped: true };
|
||||
}
|
||||
|
||||
@@ -38,11 +38,11 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P
|
||||
);
|
||||
|
||||
if (rssEnabledIndexers.length === 0) {
|
||||
await logger?.warn(`No indexers with RSS enabled, skipping`);
|
||||
logger.warn(`No indexers with RSS enabled, skipping`);
|
||||
return { success: false, message: 'No RSS-enabled indexers', skipped: true };
|
||||
}
|
||||
|
||||
await logger?.info(`Monitoring ${rssEnabledIndexers.length} RSS-enabled indexers`);
|
||||
logger.info(`Monitoring ${rssEnabledIndexers.length} RSS-enabled indexers`);
|
||||
|
||||
// Get RSS feeds from all enabled indexers
|
||||
const { getProwlarrService } = await import('../integrations/prowlarr.service');
|
||||
@@ -51,7 +51,7 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P
|
||||
const indexerIds = rssEnabledIndexers.map((i: any) => i.id);
|
||||
const rssResults = await prowlarrService.getAllRssFeeds(indexerIds);
|
||||
|
||||
await logger?.info(`Retrieved ${rssResults.length} items from RSS feeds`);
|
||||
logger.info(`Retrieved ${rssResults.length} items from RSS feeds`);
|
||||
|
||||
if (rssResults.length === 0) {
|
||||
return { success: true, message: 'No RSS results', matched: 0 };
|
||||
@@ -67,7 +67,7 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P
|
||||
take: 100,
|
||||
});
|
||||
|
||||
await logger?.info(`Found ${missingRequests.length} requests awaiting search`);
|
||||
logger.info(`Found ${missingRequests.length} requests awaiting search`);
|
||||
|
||||
if (missingRequests.length === 0) {
|
||||
return { success: true, message: 'No missing requests', matched: 0 };
|
||||
@@ -92,7 +92,7 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P
|
||||
const titleMatchCount = titleWords.filter(word => word.length > 2 && torrentTitle.includes(word)).length;
|
||||
|
||||
if (hasAuthor && titleMatchCount >= 2) {
|
||||
await logger?.info(`Match found! "${audiobook.title}" by ${audiobook.author} matches torrent: ${torrent.title}`);
|
||||
logger.info(`Match found! "${audiobook.title}" by ${audiobook.author} matches torrent: ${torrent.title}`);
|
||||
|
||||
// Trigger search job to process this request
|
||||
try {
|
||||
@@ -102,9 +102,9 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P
|
||||
author: audiobook.author,
|
||||
});
|
||||
matched++;
|
||||
await logger?.info(`Triggered search job for request ${request.id}`);
|
||||
logger.info(`Triggered search job for request ${request.id}`);
|
||||
} catch (error) {
|
||||
await logger?.error(`Failed to trigger search for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
logger.error(`Failed to trigger search for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
|
||||
// Only trigger once per request
|
||||
@@ -113,7 +113,7 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P
|
||||
}
|
||||
}
|
||||
|
||||
await logger?.info(`RSS monitoring complete: ${matched} matches found and queued for processing`);
|
||||
logger.info(`RSS monitoring complete: ${matched} matches found and queued for processing`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { OrganizeFilesPayload, getJobQueueService } from '../services/job-queue.service';
|
||||
import { prisma } from '../db';
|
||||
import { getFileOrganizer } from '../utils/file-organizer';
|
||||
import { createJobLogger } from '../utils/job-logger';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { getLibraryService } from '../services/library';
|
||||
import { getConfigService } from '../services/config.service';
|
||||
|
||||
@@ -17,11 +17,10 @@ import { getConfigService } from '../services/config.service';
|
||||
export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promise<any> {
|
||||
const { requestId, audiobookId, downloadPath, jobId } = payload;
|
||||
|
||||
// Create logger (fallback to console-only if jobId not provided)
|
||||
const logger = jobId ? createJobLogger(jobId, 'OrganizeFiles') : null;
|
||||
const logger = RMABLogger.forJob(jobId, 'OrganizeFiles');
|
||||
|
||||
await logger?.info(`Processing request ${requestId}`);
|
||||
await logger?.info(`Download path: ${downloadPath}`);
|
||||
logger.info(`Processing request ${requestId}`);
|
||||
logger.info(`Download path: ${downloadPath}`);
|
||||
|
||||
try {
|
||||
// Update request status to processing
|
||||
@@ -43,7 +42,7 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
throw new Error(`Audiobook ${audiobookId} not found`);
|
||||
}
|
||||
|
||||
await logger?.info(`Organizing: ${audiobook.title} by ${audiobook.author}`);
|
||||
logger.info(`Organizing: ${audiobook.title} by ${audiobook.author}`);
|
||||
|
||||
// Get file organizer (reads media_dir from database config)
|
||||
const organizer = await getFileOrganizer();
|
||||
@@ -65,7 +64,7 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
throw new Error(`File organization failed: ${result.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
await logger?.info(`Successfully moved ${result.filesMovedCount} files to ${result.targetPath}`);
|
||||
logger.info(`Successfully moved ${result.filesMovedCount} files to ${result.targetPath}`);
|
||||
|
||||
// Update audiobook record with file path and status
|
||||
await prisma.audiobook.update({
|
||||
@@ -89,7 +88,7 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
},
|
||||
});
|
||||
|
||||
await logger?.info(`Request ${requestId} completed successfully - status: downloaded`, {
|
||||
logger.info(`Request ${requestId} completed successfully - status: downloaded`, {
|
||||
success: true,
|
||||
message: 'Files organized successfully',
|
||||
requestId,
|
||||
@@ -128,13 +127,13 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
// Trigger scan (implementation is backend-specific)
|
||||
await libraryService.triggerLibraryScan(libraryId);
|
||||
|
||||
await logger?.info(
|
||||
logger.info(
|
||||
`Triggered ${backendMode} filesystem scan for library ${libraryId}`
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
// Log error but don't fail the job
|
||||
await logger?.error(
|
||||
logger.error(
|
||||
`Failed to trigger filesystem scan: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
{
|
||||
error: error instanceof Error ? error.stack : undefined,
|
||||
@@ -144,7 +143,7 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
// Continue - scheduled scans will eventually detect the book
|
||||
}
|
||||
} else {
|
||||
await logger?.info(
|
||||
logger.info(
|
||||
`${backendMode} filesystem scan trigger disabled (relying on filesystem watcher)`
|
||||
);
|
||||
}
|
||||
@@ -161,7 +160,7 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
errors: result.errors,
|
||||
};
|
||||
} catch (error) {
|
||||
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'File organization failed';
|
||||
|
||||
@@ -191,7 +190,7 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
|
||||
if (newAttempts < currentRequest.maxImportRetries) {
|
||||
// Still have retries left - queue for re-import
|
||||
await logger?.warn(`Retryable error for request ${requestId}, queueing for retry (attempt ${newAttempts}/${currentRequest.maxImportRetries})`);
|
||||
logger.warn(`Retryable error for request ${requestId}, queueing for retry (attempt ${newAttempts}/${currentRequest.maxImportRetries})`);
|
||||
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
@@ -213,7 +212,7 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
};
|
||||
} else {
|
||||
// Max retries exceeded - move to warn status
|
||||
await logger?.warn(`Max retries (${currentRequest.maxImportRetries}) exceeded for request ${requestId}, moving to warn status`);
|
||||
logger.warn(`Max retries (${currentRequest.maxImportRetries}) exceeded for request ${requestId}, moving to warn status`);
|
||||
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { prisma } from '../db';
|
||||
import { createJobLogger } from '../utils/job-logger';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { getLibraryService } from '../services/library';
|
||||
|
||||
export interface PlexRecentlyAddedPayload {
|
||||
@@ -16,14 +16,14 @@ export interface PlexRecentlyAddedPayload {
|
||||
|
||||
export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPayload): Promise<any> {
|
||||
const { jobId, scheduledJobId } = payload;
|
||||
const logger = jobId ? createJobLogger(jobId, 'RecentlyAdded') : null;
|
||||
const logger = RMABLogger.forJob(jobId, 'RecentlyAdded');
|
||||
|
||||
const { getConfigService } = await import('../services/config.service');
|
||||
const configService = getConfigService();
|
||||
|
||||
// Get backend mode
|
||||
const backendMode = await configService.getBackendMode();
|
||||
await logger?.info(`Backend mode: ${backendMode}`);
|
||||
logger.info(`Backend mode: ${backendMode}`);
|
||||
|
||||
// Validate configuration based on backend mode
|
||||
if (backendMode === 'audiobookshelf') {
|
||||
@@ -40,7 +40,7 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
const errorMsg = `Audiobookshelf is not configured. Missing: ${missingFields.join(', ')}`;
|
||||
await logger?.warn(errorMsg);
|
||||
logger.warn(errorMsg);
|
||||
return { success: false, message: errorMsg, skipped: true };
|
||||
}
|
||||
} else {
|
||||
@@ -57,12 +57,12 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
const errorMsg = `Plex is not configured. Missing: ${missingFields.join(', ')}`;
|
||||
await logger?.warn(errorMsg);
|
||||
logger.warn(errorMsg);
|
||||
return { success: false, message: errorMsg, skipped: true };
|
||||
}
|
||||
}
|
||||
|
||||
await logger?.info(`Starting recently added check...`);
|
||||
logger.info(`Starting recently added check...`);
|
||||
|
||||
// Get library service (automatically selects Plex or Audiobookshelf)
|
||||
const libraryService = await getLibraryService();
|
||||
@@ -76,7 +76,7 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
// Fetch top 10 recently added items using abstraction layer
|
||||
const recentItems = await libraryService.getRecentlyAdded(libraryId!, 10);
|
||||
|
||||
await logger?.info(`Found ${recentItems.length} recently added items`);
|
||||
logger.info(`Found ${recentItems.length} recently added items`);
|
||||
|
||||
if (recentItems.length === 0) {
|
||||
return { success: true, message: 'No recent items', newCount: 0, updatedCount: 0, matchedDownloads: 0 };
|
||||
@@ -112,7 +112,7 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
},
|
||||
});
|
||||
newCount++;
|
||||
await logger?.info(`New item added: ${item.title} by ${item.author}`);
|
||||
logger.info(`New item added: ${item.title} by ${item.author}`);
|
||||
} else {
|
||||
await prisma.plexLibrary.update({
|
||||
where: { plexGuid: item.externalId },
|
||||
@@ -144,7 +144,7 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
});
|
||||
|
||||
if (downloadedRequests.length > 0) {
|
||||
await logger?.info(`Checking ${downloadedRequests.length} downloaded requests for matches`);
|
||||
logger.info(`Checking ${downloadedRequests.length} downloaded requests for matches`);
|
||||
|
||||
const { findPlexMatch } = await import('../utils/audiobook-matcher');
|
||||
|
||||
@@ -159,7 +159,7 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
});
|
||||
|
||||
if (match) {
|
||||
await logger?.info(`Match found: "${audiobook.title}" → "${match.title}"`);
|
||||
logger.info(`Match found: "${audiobook.title}" → "${match.title}"`);
|
||||
|
||||
// Update audiobook with matched library item ID
|
||||
const updateData: any = { updatedAt: new Date() };
|
||||
@@ -187,18 +187,18 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
const itemId = match.plexGuid; // plexGuid contains the Audiobookshelf item ID
|
||||
const asin = audiobook.audibleAsin || undefined;
|
||||
const matchInfo = asin ? ` with ASIN ${asin}` : '';
|
||||
await logger?.info(`Triggering metadata match for matched item: ${itemId}${matchInfo}`);
|
||||
logger.info(`Triggering metadata match for matched item: ${itemId}${matchInfo}`);
|
||||
const { triggerABSItemMatch } = await import('../services/audiobookshelf/api');
|
||||
await triggerABSItemMatch(itemId, asin);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
await logger?.error(`Failed to match request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
logger.error(`Failed to match request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await logger?.info(`Complete: ${newCount} new, ${updatedCount} updated, ${matchedDownloads} matched downloads`);
|
||||
logger.info(`Complete: ${newCount} new, ${updatedCount} updated, ${matchedDownloads} matched downloads`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -209,7 +209,7 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
matchedDownloads,
|
||||
};
|
||||
} catch (error) {
|
||||
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { prisma } from '../db';
|
||||
import { createJobLogger } from '../utils/job-logger';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { getJobQueueService } from '../services/job-queue.service';
|
||||
import { getConfigService } from '../services/config.service';
|
||||
import { PathMapper } from '../utils/path-mapper';
|
||||
@@ -18,9 +18,9 @@ export interface RetryFailedImportsPayload {
|
||||
|
||||
export async function processRetryFailedImports(payload: RetryFailedImportsPayload): Promise<any> {
|
||||
const { jobId, scheduledJobId } = payload;
|
||||
const logger = jobId ? createJobLogger(jobId, 'RetryFailedImports') : null;
|
||||
const logger = RMABLogger.forJob(jobId, 'RetryFailedImports');
|
||||
|
||||
await logger?.info('Starting retry job for requests awaiting import...');
|
||||
logger.info('Starting retry job for requests awaiting import...');
|
||||
|
||||
try {
|
||||
// Load path mapping configuration once
|
||||
@@ -54,7 +54,7 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
take: 50, // Limit to 50 requests per run
|
||||
});
|
||||
|
||||
await logger?.info(`Found ${requests.length} requests awaiting import`);
|
||||
logger.info(`Found ${requests.length} requests awaiting import`);
|
||||
|
||||
if (requests.length === 0) {
|
||||
return {
|
||||
@@ -75,7 +75,7 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
const downloadHistory = request.downloadHistory[0];
|
||||
|
||||
if (!downloadHistory) {
|
||||
await logger?.warn(`No download history found for request ${request.id}, skipping`);
|
||||
logger.warn(`No download history found for request ${request.id}, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
@@ -91,16 +91,16 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
const torrent = await qbt.getTorrent(downloadHistory.torrentHash);
|
||||
const qbPath = `${torrent.save_path}/${torrent.name}`;
|
||||
downloadPath = PathMapper.transform(qbPath, mappingConfig);
|
||||
await logger?.info(
|
||||
logger.info(
|
||||
`Got download path from qBittorrent for request ${request.id}: ${qbPath}` +
|
||||
(downloadPath !== qbPath ? ` → ${downloadPath} (mapped)` : '')
|
||||
);
|
||||
} catch (qbtError) {
|
||||
// Torrent not found in qBittorrent - try to construct path from config
|
||||
await logger?.warn(`Torrent not found in qBittorrent for request ${request.id}, falling back to configured path`);
|
||||
logger.warn(`Torrent not found in qBittorrent for request ${request.id}, falling back to configured path`);
|
||||
|
||||
if (!downloadHistory.torrentName) {
|
||||
await logger?.warn(`No torrent name stored for request ${request.id}, cannot construct fallback path, skipping`);
|
||||
logger.warn(`No torrent name stored for request ${request.id}, cannot construct fallback path, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
@@ -108,14 +108,14 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
const downloadDir = await configService.get('download_dir');
|
||||
|
||||
if (!downloadDir) {
|
||||
await logger?.error(`download_dir not configured, cannot retry request ${request.id}, skipping`);
|
||||
logger.error(`download_dir not configured, cannot retry request ${request.id}, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const fallbackPath = `${downloadDir}/${downloadHistory.torrentName}`;
|
||||
downloadPath = PathMapper.transform(fallbackPath, mappingConfig);
|
||||
await logger?.info(
|
||||
logger.info(
|
||||
`Using fallback download path for request ${request.id}: ${fallbackPath}` +
|
||||
(downloadPath !== fallbackPath ? ` → ${downloadPath} (mapped)` : '')
|
||||
);
|
||||
@@ -128,15 +128,15 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
const nzbInfo = await sabnzbd.getNZB(downloadHistory.nzbId);
|
||||
if (nzbInfo && nzbInfo.downloadPath) {
|
||||
downloadPath = PathMapper.transform(nzbInfo.downloadPath, mappingConfig);
|
||||
await logger?.info(
|
||||
logger.info(
|
||||
`Got download path from SABnzbd for request ${request.id}: ${nzbInfo.downloadPath}` +
|
||||
(downloadPath !== nzbInfo.downloadPath ? ` → ${downloadPath} (mapped)` : '')
|
||||
);
|
||||
} else {
|
||||
await logger?.warn(`NZB ${downloadHistory.nzbId} not found or has no download path for request ${request.id}, falling back to configured path`);
|
||||
logger.warn(`NZB ${downloadHistory.nzbId} not found or has no download path for request ${request.id}, falling back to configured path`);
|
||||
|
||||
if (!downloadHistory.torrentName) {
|
||||
await logger?.warn(`No name stored for request ${request.id}, cannot construct fallback path, skipping`);
|
||||
logger.warn(`No name stored for request ${request.id}, cannot construct fallback path, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
@@ -144,27 +144,27 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
const downloadDir = await configService.get('download_dir');
|
||||
|
||||
if (!downloadDir) {
|
||||
await logger?.error(`download_dir not configured, cannot retry request ${request.id}, skipping`);
|
||||
logger.error(`download_dir not configured, cannot retry request ${request.id}, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const fallbackPath = `${downloadDir}/${downloadHistory.torrentName}`;
|
||||
downloadPath = PathMapper.transform(fallbackPath, mappingConfig);
|
||||
await logger?.info(
|
||||
logger.info(
|
||||
`Using fallback download path for request ${request.id}: ${fallbackPath}` +
|
||||
(downloadPath !== fallbackPath ? ` → ${downloadPath} (mapped)` : '')
|
||||
);
|
||||
}
|
||||
} catch (sabnzbdError) {
|
||||
await logger?.warn(`SABnzbd error for request ${request.id}: ${sabnzbdError instanceof Error ? sabnzbdError.message : 'Unknown error'}, skipping`);
|
||||
logger.warn(`SABnzbd error for request ${request.id}: ${sabnzbdError instanceof Error ? sabnzbdError.message : 'Unknown error'}, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// No download client ID - use fallback path
|
||||
if (!downloadHistory.torrentName) {
|
||||
await logger?.warn(`No download client ID or name for request ${request.id}, skipping`);
|
||||
logger.warn(`No download client ID or name for request ${request.id}, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
@@ -172,14 +172,14 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
const downloadDir = await configService.get('download_dir');
|
||||
|
||||
if (!downloadDir) {
|
||||
await logger?.error(`download_dir not configured, cannot retry request ${request.id}, skipping`);
|
||||
logger.error(`download_dir not configured, cannot retry request ${request.id}, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const configuredPath = `${downloadDir}/${downloadHistory.torrentName}`;
|
||||
downloadPath = PathMapper.transform(configuredPath, mappingConfig);
|
||||
await logger?.info(
|
||||
logger.info(
|
||||
`Using configured download path for request ${request.id}: ${configuredPath}` +
|
||||
(downloadPath !== configuredPath ? ` → ${downloadPath} (mapped)` : '')
|
||||
);
|
||||
@@ -191,14 +191,14 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
downloadPath
|
||||
);
|
||||
triggered++;
|
||||
await logger?.info(`Triggered organize job for request ${request.id}: ${request.audiobook.title}`);
|
||||
logger.info(`Triggered organize job for request ${request.id}: ${request.audiobook.title}`);
|
||||
} catch (error) {
|
||||
await logger?.error(`Failed to trigger organize for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
logger.error(`Failed to trigger organize for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
await logger?.info(`Triggered ${triggered}/${requests.length} organize jobs (${skipped} skipped)`);
|
||||
logger.info(`Triggered ${triggered}/${requests.length} organize jobs (${skipped} skipped)`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -208,7 +208,7 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
skipped,
|
||||
};
|
||||
} catch (error) {
|
||||
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { prisma } from '../db';
|
||||
import { createJobLogger } from '../utils/job-logger';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { getJobQueueService } from '../services/job-queue.service';
|
||||
|
||||
export interface RetryMissingTorrentsPayload {
|
||||
@@ -16,9 +16,9 @@ export interface RetryMissingTorrentsPayload {
|
||||
|
||||
export async function processRetryMissingTorrents(payload: RetryMissingTorrentsPayload): Promise<any> {
|
||||
const { jobId, scheduledJobId } = payload;
|
||||
const logger = jobId ? createJobLogger(jobId, 'RetryMissingTorrents') : null;
|
||||
const logger = RMABLogger.forJob(jobId, 'RetryMissingTorrents');
|
||||
|
||||
await logger?.info('Starting retry job for requests awaiting search...');
|
||||
logger.info('Starting retry job for requests awaiting search...');
|
||||
|
||||
try {
|
||||
// Find all active requests in awaiting_search status
|
||||
@@ -33,7 +33,7 @@ export async function processRetryMissingTorrents(payload: RetryMissingTorrentsP
|
||||
take: 50, // Limit to 50 requests per run
|
||||
});
|
||||
|
||||
await logger?.info(`Found ${requests.length} requests awaiting search`);
|
||||
logger.info(`Found ${requests.length} requests awaiting search`);
|
||||
|
||||
if (requests.length === 0) {
|
||||
return {
|
||||
@@ -55,13 +55,13 @@ export async function processRetryMissingTorrents(payload: RetryMissingTorrentsP
|
||||
author: request.audiobook.author,
|
||||
});
|
||||
triggered++;
|
||||
await logger?.info(`Triggered search for request ${request.id}: ${request.audiobook.title}`);
|
||||
logger.info(`Triggered search for request ${request.id}: ${request.audiobook.title}`);
|
||||
} catch (error) {
|
||||
await logger?.error(`Failed to trigger search for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
logger.error(`Failed to trigger search for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
await logger?.info(`Triggered ${triggered}/${requests.length} search jobs`);
|
||||
logger.info(`Triggered ${triggered}/${requests.length} search jobs`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -70,7 +70,7 @@ export async function processRetryMissingTorrents(payload: RetryMissingTorrentsP
|
||||
triggered,
|
||||
};
|
||||
} catch (error) {
|
||||
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ScanPlexPayload } from '../services/job-queue.service';
|
||||
import { prisma } from '../db';
|
||||
import { getLibraryService } from '../services/library';
|
||||
import { getConfigService } from '../services/config.service';
|
||||
import { createJobLogger } from '../utils/job-logger';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* Process library scan job
|
||||
@@ -19,9 +19,9 @@ import { createJobLogger } from '../utils/job-logger';
|
||||
export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
const { libraryId, partial, path, jobId } = payload;
|
||||
|
||||
const logger = jobId ? createJobLogger(jobId, 'ScanLibrary') : null;
|
||||
const logger = RMABLogger.forJob(jobId, 'ScanLibrary');
|
||||
|
||||
await logger?.info(`Scanning library ${libraryId || 'default'}${partial ? ' (partial)' : ''}`);
|
||||
logger.info(`Scanning library ${libraryId || 'default'}${partial ? ' (partial)' : ''}`);
|
||||
|
||||
try {
|
||||
// 1. Get library service (automatically selects Plex or Audiobookshelf based on config)
|
||||
@@ -29,7 +29,7 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
const configService = getConfigService();
|
||||
const backendMode = await configService.getBackendMode();
|
||||
|
||||
await logger?.info(`Backend mode: ${backendMode}`);
|
||||
logger.info(`Backend mode: ${backendMode}`);
|
||||
|
||||
// 2. Get configured library ID
|
||||
let targetLibraryId = libraryId;
|
||||
@@ -50,12 +50,12 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
}
|
||||
}
|
||||
|
||||
await logger?.info(`Fetching content from library ${targetLibraryId}`);
|
||||
logger.info(`Fetching content from library ${targetLibraryId}`);
|
||||
|
||||
// 3. Get all audiobooks from library using abstraction layer
|
||||
const libraryItems = await libraryService.getLibraryItems(targetLibraryId);
|
||||
|
||||
await logger?.info(`Found ${libraryItems.length} items in library`);
|
||||
logger.info(`Found ${libraryItems.length} items in library`);
|
||||
|
||||
let newCount = 0;
|
||||
let updatedCount = 0;
|
||||
@@ -120,7 +120,7 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
});
|
||||
|
||||
newCount++;
|
||||
await logger?.info(`Added new: "${item.title}" by ${item.author}`);
|
||||
logger.info(`Added new: "${item.title}" by ${item.author}`);
|
||||
|
||||
results.push({
|
||||
id: newLibraryItem.id,
|
||||
@@ -130,16 +130,16 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
await logger?.error(`Failed to process "${item.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
logger.error(`Failed to process "${item.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
skippedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
await logger?.info(`Scan complete: ${libraryItems.length} items scanned, ${newCount} new, ${updatedCount} updated, ${skippedCount} skipped`);
|
||||
logger.info(`Scan complete: ${libraryItems.length} items scanned, ${newCount} new, ${updatedCount} updated, ${skippedCount} skipped`);
|
||||
|
||||
// 5. Remove stale records from plex_library (items no longer in the actual library)
|
||||
// This ensures the database is a fresh snapshot of the library state
|
||||
await logger?.info(`Checking for stale library records...`);
|
||||
logger.info(`Checking for stale library records...`);
|
||||
|
||||
const scannedPlexGuids = libraryItems
|
||||
.filter(item => item.externalId)
|
||||
@@ -163,7 +163,7 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
});
|
||||
|
||||
if (staleLibraryItems.length > 0) {
|
||||
await logger?.info(`Found ${staleLibraryItems.length} stale library records to remove`);
|
||||
logger.info(`Found ${staleLibraryItems.length} stale library records to remove`);
|
||||
|
||||
// For each stale library item, clean up references
|
||||
for (const staleItem of staleLibraryItems) {
|
||||
@@ -214,7 +214,7 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
}
|
||||
}
|
||||
|
||||
await logger?.info(`Reset audiobook "${staleItem.title}" (no longer in library)`);
|
||||
logger.info(`Reset audiobook "${staleItem.title}" (no longer in library)`);
|
||||
}
|
||||
|
||||
// Delete the stale library record
|
||||
@@ -224,21 +224,21 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
|
||||
staleRemovedCount++;
|
||||
} catch (error) {
|
||||
await logger?.error(`Failed to remove stale library item "${staleItem.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
logger.error(`Failed to remove stale library item "${staleItem.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
await logger?.info(`Removed ${staleRemovedCount} stale records, reset ${audiobooksReset} audiobooks and ${requestsReset} requests`);
|
||||
logger.info(`Removed ${staleRemovedCount} stale records, reset ${audiobooksReset} audiobooks and ${requestsReset} requests`);
|
||||
} else {
|
||||
await logger?.info(`No stale library records found`);
|
||||
logger.info(`No stale library records found`);
|
||||
}
|
||||
} else {
|
||||
await logger?.warn(`Scan returned no items - skipping stale record cleanup to prevent data loss`);
|
||||
logger.warn(`Scan returned no items - skipping stale record cleanup to prevent data loss`);
|
||||
}
|
||||
|
||||
// 5b. Clean up orphaned audiobooks (audiobooks with plexGuid/absItemId that don't exist in plex_library)
|
||||
// This handles cases where the library record was already deleted but audiobook record wasn't updated
|
||||
await logger?.info(`Checking for orphaned audiobooks...`);
|
||||
logger.info(`Checking for orphaned audiobooks...`);
|
||||
|
||||
const allPlexGuidsInLibrary = await prisma.plexLibrary.findMany({
|
||||
select: { plexGuid: true },
|
||||
@@ -277,7 +277,7 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
|
||||
// This audiobook is orphaned - its library link points to nothing
|
||||
try {
|
||||
await logger?.info(`Found orphaned audiobook: "${audiobook.title}" (linked to non-existent library item)`);
|
||||
logger.info(`Found orphaned audiobook: "${audiobook.title}" (linked to non-existent library item)`);
|
||||
|
||||
// Clear library linkage
|
||||
await prisma.audiobook.update({
|
||||
@@ -306,18 +306,18 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
await logger?.error(`Failed to reset orphaned audiobook "${audiobook.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
logger.error(`Failed to reset orphaned audiobook "${audiobook.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (orphanedAudiobooksReset > 0) {
|
||||
await logger?.info(`Reset ${orphanedAudiobooksReset} orphaned audiobooks and ${orphanedRequestsReset} requests`);
|
||||
logger.info(`Reset ${orphanedAudiobooksReset} orphaned audiobooks and ${orphanedRequestsReset} requests`);
|
||||
} else {
|
||||
await logger?.info(`No orphaned audiobooks found`);
|
||||
logger.info(`No orphaned audiobooks found`);
|
||||
}
|
||||
|
||||
// 6. Match downloaded requests against library
|
||||
await logger?.info(`Checking for downloaded requests to match...`);
|
||||
logger.info(`Checking for downloaded requests to match...`);
|
||||
const downloadedRequests = await prisma.request.findMany({
|
||||
where: {
|
||||
status: 'downloaded',
|
||||
@@ -327,7 +327,7 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
take: 50, // Limit to prevent overwhelming
|
||||
});
|
||||
|
||||
await logger?.info(`Found ${downloadedRequests.length} downloaded requests to match`);
|
||||
logger.info(`Found ${downloadedRequests.length} downloaded requests to match`);
|
||||
|
||||
let matchedCount = 0;
|
||||
const { findPlexMatch } = await import('../utils/audiobook-matcher');
|
||||
@@ -346,7 +346,7 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
});
|
||||
|
||||
if (match) {
|
||||
await logger?.info(`Match found! "${audiobook.title}" -> "${match.title}"`);
|
||||
logger.info(`Match found! "${audiobook.title}" -> "${match.title}"`);
|
||||
|
||||
// Update audiobook with matched library item ID (plexGuid or abs_item_id)
|
||||
const updateData: any = { updatedAt: new Date() };
|
||||
@@ -379,17 +379,17 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
const itemId = match.plexGuid; // plexGuid contains the Audiobookshelf item ID
|
||||
const asin = audiobook.audibleAsin || undefined;
|
||||
const matchInfo = asin ? ` with ASIN ${asin}` : '';
|
||||
await logger?.info(`Triggering metadata match for matched item: ${itemId}${matchInfo}`);
|
||||
logger.info(`Triggering metadata match for matched item: ${itemId}${matchInfo}`);
|
||||
const { triggerABSItemMatch } = await import('../services/audiobookshelf/api');
|
||||
await triggerABSItemMatch(itemId, asin);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
await logger?.error(`Failed to match request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
logger.error(`Failed to match request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
await logger?.info(`Matched ${matchedCount}/${downloadedRequests.length} downloaded requests`, {
|
||||
logger.info(`Matched ${matchedCount}/${downloadedRequests.length} downloaded requests`, {
|
||||
totalScanned: libraryItems.length,
|
||||
newCount,
|
||||
updatedCount,
|
||||
@@ -420,7 +420,7 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
matchedDownloads: matchedCount,
|
||||
};
|
||||
} catch (error) {
|
||||
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { SearchIndexersPayload, getJobQueueService } from '../services/job-queue
|
||||
import { prisma } from '../db';
|
||||
import { getProwlarrService } from '../integrations/prowlarr.service';
|
||||
import { getRankingAlgorithm } from '../utils/ranking-algorithm';
|
||||
import { createJobLogger } from '../utils/job-logger';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* Process search indexers job
|
||||
@@ -16,9 +16,9 @@ import { createJobLogger } from '../utils/job-logger';
|
||||
export async function processSearchIndexers(payload: SearchIndexersPayload): Promise<any> {
|
||||
const { requestId, audiobook, jobId } = payload;
|
||||
|
||||
const logger = jobId ? createJobLogger(jobId, 'SearchIndexers') : null;
|
||||
const logger = RMABLogger.forJob(jobId, 'SearchIndexers');
|
||||
|
||||
await logger?.info(`Processing request ${requestId} for "${audiobook.title}"`);
|
||||
logger.info(`Processing request ${requestId} for "${audiobook.title}"`);
|
||||
|
||||
try {
|
||||
// Update request status to searching
|
||||
@@ -56,7 +56,7 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
|
||||
const flagConfigStr = await configService.get('indexer_flag_config');
|
||||
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
|
||||
|
||||
await logger?.info(`Searching ${enabledIndexerIds.length} enabled indexers`);
|
||||
logger.info(`Searching ${enabledIndexerIds.length} enabled indexers`);
|
||||
|
||||
// Get Prowlarr service
|
||||
const prowlarr = await getProwlarrService();
|
||||
@@ -64,7 +64,7 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
|
||||
// Build search query (title only - cast wide net, let ranking filter)
|
||||
const searchQuery = audiobook.title;
|
||||
|
||||
await logger?.info(`Searching for: "${searchQuery}"`);
|
||||
logger.info(`Searching for: "${searchQuery}"`);
|
||||
|
||||
// Search indexers - ONLY enabled ones
|
||||
const searchResults = await prowlarr.search(searchQuery, {
|
||||
@@ -74,11 +74,11 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
|
||||
indexerIds: enabledIndexerIds, // Filter by enabled indexers
|
||||
});
|
||||
|
||||
await logger?.info(`Found ${searchResults.length} raw results`);
|
||||
logger.info(`Found ${searchResults.length} raw results`);
|
||||
|
||||
if (searchResults.length === 0) {
|
||||
// No results found - queue for re-search instead of failing
|
||||
await logger?.warn(`No torrents found for request ${requestId}, marking as awaiting_search`);
|
||||
logger.warn(`No torrents found for request ${requestId}, marking as awaiting_search`);
|
||||
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
@@ -117,14 +117,14 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
|
||||
result.score >= 50 && result.finalScore < 50
|
||||
).length;
|
||||
|
||||
await logger?.info(`Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (50/100 base + final)`);
|
||||
logger.info(`Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (50/100 base + final)`);
|
||||
if (disqualifiedByNegativeBonus > 0) {
|
||||
await logger?.info(`${disqualifiedByNegativeBonus} torrents disqualified by negative flag bonuses`);
|
||||
logger.info(`${disqualifiedByNegativeBonus} torrents disqualified by negative flag bonuses`);
|
||||
}
|
||||
|
||||
if (filteredResults.length === 0) {
|
||||
// No quality results found - queue for re-search instead of failing
|
||||
await logger?.warn(`No quality matches found for request ${requestId} (all below 50/100), marking as awaiting_search`);
|
||||
logger.warn(`No quality matches found for request ${requestId} (all below 50/100), marking as awaiting_search`);
|
||||
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
@@ -148,38 +148,38 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
|
||||
|
||||
// Log top 3 results with detailed breakdown
|
||||
const top3 = filteredResults.slice(0, 3);
|
||||
await logger?.info(`==================== RANKING DEBUG ====================`);
|
||||
await logger?.info(`Requested Title: "${audiobook.title}"`);
|
||||
await logger?.info(`Requested Author: "${audiobook.author}"`);
|
||||
await logger?.info(`Top ${top3.length} results (out of ${filteredResults.length} above threshold):`);
|
||||
await logger?.info(`--------------------------------------------------------`);
|
||||
logger.info(`==================== RANKING DEBUG ====================`);
|
||||
logger.info(`Requested Title: "${audiobook.title}"`);
|
||||
logger.info(`Requested Author: "${audiobook.author}"`);
|
||||
logger.info(`Top ${top3.length} results (out of ${filteredResults.length} above threshold):`);
|
||||
logger.info(`--------------------------------------------------------`);
|
||||
for (let i = 0; i < top3.length; i++) {
|
||||
const result = top3[i];
|
||||
await logger?.info(`${i + 1}. "${result.title}"`);
|
||||
await logger?.info(` Indexer: ${result.indexer}${result.indexerId ? ` (ID: ${result.indexerId})` : ''}`);
|
||||
await logger?.info(``);
|
||||
await logger?.info(` Base Score: ${result.score.toFixed(1)}/100`);
|
||||
await logger?.info(` - Title/Author Match: ${result.breakdown.matchScore.toFixed(1)}/60`);
|
||||
await logger?.info(` - Format Quality: ${result.breakdown.formatScore.toFixed(1)}/25 (${result.format || 'unknown'})`);
|
||||
await logger?.info(` - Seeder Count: ${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders !== undefined ? result.seeders + ' seeders' : 'N/A for Usenet'})`);
|
||||
await logger?.info(``);
|
||||
await logger?.info(` Bonus Points: +${result.bonusPoints.toFixed(1)}`);
|
||||
logger.info(`${i + 1}. "${result.title}"`);
|
||||
logger.info(` Indexer: ${result.indexer}${result.indexerId ? ` (ID: ${result.indexerId})` : ''}`);
|
||||
logger.info(``);
|
||||
logger.info(` Base Score: ${result.score.toFixed(1)}/100`);
|
||||
logger.info(` - Title/Author Match: ${result.breakdown.matchScore.toFixed(1)}/60`);
|
||||
logger.info(` - Format Quality: ${result.breakdown.formatScore.toFixed(1)}/25 (${result.format || 'unknown'})`);
|
||||
logger.info(` - Seeder Count: ${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders !== undefined ? result.seeders + ' seeders' : 'N/A for Usenet'})`);
|
||||
logger.info(``);
|
||||
logger.info(` Bonus Points: +${result.bonusPoints.toFixed(1)}`);
|
||||
if (result.bonusModifiers.length > 0) {
|
||||
for (const mod of result.bonusModifiers) {
|
||||
await logger?.info(` - ${mod.reason}: +${mod.points.toFixed(1)}`);
|
||||
logger.info(` - ${mod.reason}: +${mod.points.toFixed(1)}`);
|
||||
}
|
||||
}
|
||||
await logger?.info(``);
|
||||
await logger?.info(` Final Score: ${result.finalScore.toFixed(1)}`);
|
||||
logger.info(``);
|
||||
logger.info(` Final Score: ${result.finalScore.toFixed(1)}`);
|
||||
if (result.breakdown.notes.length > 0) {
|
||||
await logger?.info(` Notes: ${result.breakdown.notes.join(', ')}`);
|
||||
logger.info(` Notes: ${result.breakdown.notes.join(', ')}`);
|
||||
}
|
||||
if (i < top3.length - 1) {
|
||||
await logger?.info(`--------------------------------------------------------`);
|
||||
logger.info(`--------------------------------------------------------`);
|
||||
}
|
||||
}
|
||||
await logger?.info(`========================================================`);
|
||||
await logger?.info(`Selected best result: ${bestResult.title} (final score: ${bestResult.finalScore.toFixed(1)})`);
|
||||
logger.info(`========================================================`);
|
||||
logger.info(`Selected best result: ${bestResult.title} (final score: ${bestResult.finalScore.toFixed(1)})`);
|
||||
|
||||
// Trigger download job with best result
|
||||
const jobQueue = getJobQueueService();
|
||||
@@ -202,7 +202,7 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
*/
|
||||
|
||||
import { getConfigService } from '../config.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('Audiobookshelf');
|
||||
|
||||
interface ABSRequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
@@ -146,6 +149,6 @@ export async function triggerABSItemMatch(itemId: string, asin?: string) {
|
||||
});
|
||||
} catch (error) {
|
||||
// Don't throw - matching is best-effort, scan should continue even if match fails
|
||||
console.error(`[ABS] Failed to trigger match for item ${itemId}:`, error instanceof Error ? error.message : error);
|
||||
logger.error(`Failed to trigger match for item ${itemId}`, { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('LocalAuth');
|
||||
|
||||
interface LocalLoginParams extends CallbackParams {
|
||||
username: string;
|
||||
@@ -83,7 +86,7 @@ export class LocalAuthProvider implements IAuthProvider {
|
||||
const decryptedHash = this.encryptionService.decrypt(user.authToken || '');
|
||||
passwordValid = await bcrypt.compare(password, decryptedHash);
|
||||
} catch (error) {
|
||||
console.error('[LocalAuthProvider] Password verification failed:', error);
|
||||
logger.error('Password verification failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
return { success: false, error: 'Invalid username or password' };
|
||||
}
|
||||
|
||||
@@ -98,7 +101,7 @@ export class LocalAuthProvider implements IAuthProvider {
|
||||
});
|
||||
|
||||
// Generate tokens
|
||||
console.log('[LocalAuthProvider] Generating tokens for user:', {
|
||||
logger.info('Generating tokens for user', {
|
||||
id: user.id,
|
||||
plexId: user.plexId,
|
||||
username: user.plexUsername,
|
||||
@@ -113,7 +116,7 @@ export class LocalAuthProvider implements IAuthProvider {
|
||||
isAdmin: user.role === 'admin',
|
||||
});
|
||||
|
||||
console.log('[LocalAuthProvider] Tokens generated, returning user data');
|
||||
logger.info('Tokens generated, returning user data');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -126,7 +129,7 @@ export class LocalAuthProvider implements IAuthProvider {
|
||||
tokens,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[LocalAuthProvider] Login failed:', error);
|
||||
logger.error('Login failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Authentication failed',
|
||||
@@ -224,7 +227,7 @@ export class LocalAuthProvider implements IAuthProvider {
|
||||
tokens,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[LocalAuthProvider] Registration failed:', error);
|
||||
logger.error('Registration failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Registration failed',
|
||||
@@ -243,7 +246,7 @@ export class LocalAuthProvider implements IAuthProvider {
|
||||
role: userInfo.isAdmin ? 'admin' : 'user',
|
||||
};
|
||||
|
||||
console.log('[LocalAuthProvider] JWT token payload:', tokenPayload);
|
||||
logger.debug('JWT token payload', { tokenPayload });
|
||||
|
||||
const accessToken = generateAccessToken(tokenPayload);
|
||||
const refreshToken = generateRefreshToken(userInfo.id);
|
||||
@@ -288,7 +291,7 @@ export class LocalAuthProvider implements IAuthProvider {
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[LocalAuthProvider] Access validation failed:', error);
|
||||
logger.error('Access validation failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
|
||||
import { getBaseUrl } from '@/lib/utils/url';
|
||||
import { getSchedulerService } from '@/lib/services/scheduler.service';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('OIDCAuth');
|
||||
|
||||
// In-memory storage for OIDC flow state (temporary until callback completes)
|
||||
// In production, this could be replaced with Redis for multi-instance support
|
||||
@@ -109,7 +112,7 @@ export class OIDCAuthProvider implements IAuthProvider {
|
||||
state,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OIDCAuthProvider] Failed to initiate login:', error);
|
||||
logger.error('Failed to initiate login', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw new Error('Failed to initiate OIDC authentication');
|
||||
}
|
||||
}
|
||||
@@ -150,14 +153,12 @@ export class OIDCAuthProvider implements IAuthProvider {
|
||||
const client = await this.getClient();
|
||||
const redirectUri = await this.getRedirectUri();
|
||||
|
||||
if (process.env.LOG_LEVEL === 'debug') {
|
||||
console.debug('[OIDCAuthProvider] Exchanging code for tokens', {
|
||||
redirectUri,
|
||||
hasCode: !!code,
|
||||
hasState: !!state,
|
||||
stateMatches: state === flowState.state,
|
||||
});
|
||||
}
|
||||
logger.debug('Exchanging code for tokens', {
|
||||
redirectUri,
|
||||
hasCode: !!code,
|
||||
hasState: !!state,
|
||||
stateMatches: state === flowState.state,
|
||||
});
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokenSet = await client.callback(
|
||||
@@ -259,7 +260,7 @@ export class OIDCAuthProvider implements IAuthProvider {
|
||||
isFirstLogin: result.isFirstLogin,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OIDCAuthProvider] Callback failed:', error);
|
||||
logger.error('Callback failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Authentication failed',
|
||||
@@ -282,7 +283,7 @@ export class OIDCAuthProvider implements IAuthProvider {
|
||||
const requiredGroup = await this.configService.get('oidc.access_group_value');
|
||||
|
||||
if (!requiredGroup) {
|
||||
console.error('[OIDCAuthProvider] Group claim access control enabled but no required group configured');
|
||||
logger.error('Group claim access control enabled but no required group configured');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -432,7 +433,7 @@ export class OIDCAuthProvider implements IAuthProvider {
|
||||
// If this is the first user, trigger initial jobs (Audible refresh + Library scan)
|
||||
// This happens after OIDC-only setup where no admin was created during wizard
|
||||
if (isFirstUser) {
|
||||
console.log('[OIDCAuthProvider] First OIDC user created - triggering initial jobs');
|
||||
logger.info('First OIDC user created - triggering initial jobs');
|
||||
|
||||
// Check if initial jobs have already been run (avoid duplicate runs)
|
||||
const initialJobsRun = await this.configService.get('system.initial_jobs_run');
|
||||
@@ -442,7 +443,7 @@ export class OIDCAuthProvider implements IAuthProvider {
|
||||
|
||||
// Trigger jobs in background (don't block authentication)
|
||||
this.triggerInitialJobs().catch(err => {
|
||||
console.error('[OIDCAuthProvider] Failed to trigger initial jobs:', err);
|
||||
logger.error('Failed to trigger initial jobs', { error: err instanceof Error ? err.message : String(err) });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -476,22 +477,22 @@ export class OIDCAuthProvider implements IAuthProvider {
|
||||
where: { type: 'plex_library_scan' },
|
||||
});
|
||||
|
||||
console.log('[OIDCAuthProvider] Triggering initial jobs...');
|
||||
logger.info('Triggering initial jobs...');
|
||||
|
||||
// Trigger Audible refresh
|
||||
if (audibleJob) {
|
||||
await schedulerService.triggerJobNow(audibleJob.id);
|
||||
console.log('[OIDCAuthProvider] Triggered Audible refresh job');
|
||||
logger.info('Triggered Audible refresh job');
|
||||
} else {
|
||||
console.warn('[OIDCAuthProvider] Audible refresh job not found');
|
||||
logger.warn('Audible refresh job not found');
|
||||
}
|
||||
|
||||
// Trigger Library scan
|
||||
if (libraryJob) {
|
||||
await schedulerService.triggerJobNow(libraryJob.id);
|
||||
console.log('[OIDCAuthProvider] Triggered Library scan job');
|
||||
logger.info('Triggered Library scan job');
|
||||
} else {
|
||||
console.warn('[OIDCAuthProvider] Library scan job not found');
|
||||
logger.warn('Library scan job not found');
|
||||
}
|
||||
|
||||
// Mark initial jobs as run
|
||||
@@ -501,9 +502,9 @@ export class OIDCAuthProvider implements IAuthProvider {
|
||||
create: { key: 'system.initial_jobs_run', value: 'true' },
|
||||
});
|
||||
|
||||
console.log('[OIDCAuthProvider] Initial jobs triggered successfully');
|
||||
logger.info('Initial jobs triggered successfully');
|
||||
} catch (error) {
|
||||
console.error('[OIDCAuthProvider] Error triggering initial jobs:', error);
|
||||
logger.error('Error triggering initial jobs', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -556,7 +557,7 @@ export class OIDCAuthProvider implements IAuthProvider {
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[OIDCAuthProvider] Access validation failed:', error);
|
||||
logger.error('Access validation failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
|
||||
import { getBaseUrl } from '@/lib/utils/url';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('PlexAuth');
|
||||
|
||||
export class PlexAuthProvider implements IAuthProvider {
|
||||
type: 'plex' = 'plex';
|
||||
@@ -43,7 +46,7 @@ export class PlexAuthProvider implements IAuthProvider {
|
||||
pinId: pin.id.toString(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[PlexAuthProvider] Failed to initiate login:', error);
|
||||
logger.error('Failed to initiate login', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw new Error('Failed to initiate Plex authentication');
|
||||
}
|
||||
}
|
||||
@@ -137,7 +140,7 @@ export class PlexAuthProvider implements IAuthProvider {
|
||||
tokens,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[PlexAuthProvider] Callback failed:', error);
|
||||
logger.error('Callback failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Authentication failed',
|
||||
@@ -184,7 +187,7 @@ export class PlexAuthProvider implements IAuthProvider {
|
||||
decryptedToken
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[PlexAuthProvider] Access validation failed:', error);
|
||||
logger.error('Access validation failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getEncryptionService } from './encryption.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('Config');
|
||||
|
||||
/**
|
||||
* Configuration update payload
|
||||
@@ -70,7 +73,7 @@ export class ConfigurationService {
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`[Config] Failed to get config key "${key}":`, error);
|
||||
logger.error(`Failed to get config key "${key}"`, { error: error instanceof Error ? error.message : String(error) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -119,7 +122,7 @@ export class ConfigurationService {
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`[Config] Failed to get category "${category}":`, error);
|
||||
logger.error(`Failed to get category "${category}"`, { error: error instanceof Error ? error.message : String(error) });
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -144,7 +147,7 @@ export class ConfigurationService {
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[Config] Failed to get all configuration:', error);
|
||||
logger.error('Failed to get all configuration', { error: error instanceof Error ? error.message : String(error) });
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -186,7 +189,7 @@ export class ConfigurationService {
|
||||
this.clearCache(update.key);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Config] Failed to set configuration:', error);
|
||||
logger.error('Failed to set configuration', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ import * as cheerio from 'cheerio';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { JobLogger } from '../utils/job-logger';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
// Module-level logger (renamed to avoid shadowing function parameter 'logger')
|
||||
const moduleLogger = RMABLogger.create('EbookScraper');
|
||||
|
||||
export interface EbookDownloadResult {
|
||||
success: boolean;
|
||||
@@ -23,9 +27,6 @@ const MAX_SLOW_LINK_ATTEMPTS = 5;
|
||||
const MAX_RETRIES = 3;
|
||||
const FLARESOLVERR_TIMEOUT_MS = 60000; // 60 seconds for FlareSolverr requests
|
||||
|
||||
// Debug logging
|
||||
const DEBUG_ENABLED = process.env.LOG_LEVEL === 'debug';
|
||||
|
||||
// In-memory cache for MD5 lookups (prevents re-scraping same ASIN)
|
||||
const md5Cache = new Map<string, string | null>();
|
||||
|
||||
@@ -94,13 +95,9 @@ async function fetchHtml(
|
||||
// Try FlareSolverr first if configured
|
||||
if (flaresolverrUrl) {
|
||||
try {
|
||||
if (DEBUG_ENABLED) {
|
||||
console.log(`[EbookScraper] Using FlareSolverr for: ${url}`);
|
||||
}
|
||||
moduleLogger.debug(`Using FlareSolverr for: ${url}`);
|
||||
const html = await fetchViaFlareSolverr(url, flaresolverrUrl);
|
||||
if (DEBUG_ENABLED) {
|
||||
console.log(`[EbookScraper] FlareSolverr returned HTML length: ${html.length}`);
|
||||
}
|
||||
moduleLogger.debug(`FlareSolverr returned HTML length: ${html.length}`);
|
||||
return html;
|
||||
} catch (error) {
|
||||
await logger?.warn(
|
||||
@@ -108,17 +105,13 @@ async function fetchHtml(
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
}`
|
||||
);
|
||||
if (DEBUG_ENABLED) {
|
||||
console.log(`[EbookScraper] FlareSolverr error:`, error);
|
||||
}
|
||||
moduleLogger.debug('FlareSolverr error', { error: error instanceof Error ? error.message : String(error) });
|
||||
// Fall through to direct request
|
||||
}
|
||||
}
|
||||
|
||||
// Direct request (may fail with Cloudflare protection)
|
||||
if (DEBUG_ENABLED) {
|
||||
console.log(`[EbookScraper] Using direct request for: ${url}`);
|
||||
}
|
||||
moduleLogger.debug(`Using direct request for: ${url}`);
|
||||
const response = await retryRequest(() =>
|
||||
axios.get(url, {
|
||||
headers: { 'User-Agent': USER_AGENT },
|
||||
@@ -126,9 +119,7 @@ async function fetchHtml(
|
||||
})
|
||||
);
|
||||
|
||||
if (DEBUG_ENABLED) {
|
||||
console.log(`[EbookScraper] Direct request returned data length: ${response.data?.length || 0}`);
|
||||
}
|
||||
moduleLogger.debug(`Direct request returned data length: ${response.data?.length || 0}`);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
@@ -337,9 +328,7 @@ async function searchByAsin(
|
||||
const formatParam = format && format !== 'any' ? `ext=${format}&` : '';
|
||||
const searchUrl = `${baseUrl}/search?${formatParam}q=%22asin:${asin}%22`;
|
||||
|
||||
if (DEBUG_ENABLED) {
|
||||
console.log(`[EbookScraper] ASIN search URL: ${searchUrl}`);
|
||||
}
|
||||
moduleLogger.debug(`ASIN search URL: ${searchUrl}`);
|
||||
|
||||
const html = await fetchHtml(searchUrl, flaresolverrUrl, logger);
|
||||
const $ = cheerio.load(html);
|
||||
@@ -358,26 +347,24 @@ async function searchByAsin(
|
||||
return true;
|
||||
});
|
||||
|
||||
if (DEBUG_ENABLED) {
|
||||
console.log(`[EbookScraper] ASIN search HTML length: ${html.length}`);
|
||||
// Log the page title to see what we got
|
||||
const pageTitle = $('title').text();
|
||||
console.log(`[EbookScraper] ASIN search page title: ${pageTitle}`);
|
||||
// Count how many md5 links we found (excluding recent downloads)
|
||||
const allMd5Links = $('a[href*="/md5/"]').length;
|
||||
console.log(`[EbookScraper] Total MD5 links on page: ${allMd5Links}, search results only: ${searchResultLinks.length}`);
|
||||
}
|
||||
// Debug logging for ASIN search
|
||||
const pageTitle = $('title').text();
|
||||
const allMd5Links = $('a[href*="/md5/"]').length;
|
||||
moduleLogger.debug('ASIN search results', {
|
||||
htmlLength: html.length,
|
||||
pageTitle,
|
||||
totalMd5Links: allMd5Links,
|
||||
searchResultLinks: searchResultLinks.length
|
||||
});
|
||||
|
||||
// Extract MD5 from first search result link
|
||||
const firstResult = searchResultLinks.first();
|
||||
const href = firstResult.attr('href');
|
||||
|
||||
if (DEBUG_ENABLED && firstResult.length > 0) {
|
||||
// Try to get the text/title of the first result
|
||||
if (firstResult.length > 0) {
|
||||
const resultText = firstResult.text().trim().substring(0, 100);
|
||||
const parentText = firstResult.parent().text().trim().substring(0, 100);
|
||||
console.log(`[EbookScraper] First result link text: "${resultText}"`);
|
||||
console.log(`[EbookScraper] First result parent text: "${parentText}"`);
|
||||
moduleLogger.debug('First result details', { resultText, parentText });
|
||||
}
|
||||
|
||||
if (!href) {
|
||||
@@ -390,9 +377,7 @@ async function searchByAsin(
|
||||
const md5Match = href.match(/\/md5\/([a-f0-9]+)/);
|
||||
const md5 = md5Match ? md5Match[1] : null;
|
||||
|
||||
if (DEBUG_ENABLED) {
|
||||
console.log(`[EbookScraper] Extracted MD5 from ASIN search: ${md5}`);
|
||||
}
|
||||
moduleLogger.debug(`Extracted MD5 from ASIN search: ${md5}`);
|
||||
|
||||
// Cache result
|
||||
md5Cache.set(cacheKey, md5);
|
||||
@@ -451,9 +436,7 @@ async function searchByTitle(
|
||||
// Empty raw query (we're using specific terms instead)
|
||||
searchUrl += '&q=';
|
||||
|
||||
if (DEBUG_ENABLED) {
|
||||
console.log(`[EbookScraper] Title search URL: ${searchUrl}`);
|
||||
}
|
||||
moduleLogger.debug(`Title search URL: ${searchUrl}`);
|
||||
|
||||
const html = await fetchHtml(searchUrl, flaresolverrUrl, logger);
|
||||
const $ = cheerio.load(html);
|
||||
@@ -471,10 +454,8 @@ async function searchByTitle(
|
||||
return true;
|
||||
});
|
||||
|
||||
if (DEBUG_ENABLED) {
|
||||
const allMd5Links = $('a[href*="/md5/"]').length;
|
||||
console.log(`[EbookScraper] Title search: Total MD5 links: ${allMd5Links}, search results only: ${searchResultLinks.length}`);
|
||||
}
|
||||
const allMd5Links = $('a[href*="/md5/"]').length;
|
||||
moduleLogger.debug('Title search results', { totalMd5Links: allMd5Links, searchResultLinks: searchResultLinks.length });
|
||||
|
||||
// Extract MD5 from first search result link
|
||||
const firstResult = searchResultLinks.first();
|
||||
@@ -516,44 +497,35 @@ async function getSlowDownloadLinks(
|
||||
try {
|
||||
const md5Url = `${baseUrl}/md5/${md5}`;
|
||||
|
||||
if (DEBUG_ENABLED) {
|
||||
console.log(`[EbookScraper] Fetching MD5 page: ${md5Url}`);
|
||||
}
|
||||
moduleLogger.debug(`Fetching MD5 page: ${md5Url}`);
|
||||
|
||||
const html = await fetchHtml(md5Url, flaresolverrUrl, logger);
|
||||
|
||||
if (DEBUG_ENABLED) {
|
||||
console.log(`[EbookScraper] HTML length: ${html.length}`);
|
||||
console.log(`[EbookScraper] HTML preview (first 500 chars): ${html.substring(0, 500)}`);
|
||||
// Check if we got a Cloudflare challenge page
|
||||
if (html.includes('challenge-running') || html.includes('cf-browser-verification')) {
|
||||
console.log(`[EbookScraper] WARNING: Appears to be Cloudflare challenge page!`);
|
||||
}
|
||||
moduleLogger.debug('MD5 page HTML', { length: html.length, preview: html.substring(0, 500) });
|
||||
// Check if we got a Cloudflare challenge page
|
||||
if (html.includes('challenge-running') || html.includes('cf-browser-verification')) {
|
||||
moduleLogger.warn('Appears to be Cloudflare challenge page');
|
||||
}
|
||||
|
||||
const $ = cheerio.load(html);
|
||||
const slowLinks: string[] = [];
|
||||
|
||||
// Debug: count all links
|
||||
if (DEBUG_ENABLED) {
|
||||
const allLinks = $('a').length;
|
||||
const slowDownloadLinks = $('a[href*="/slow_download/"]').length;
|
||||
const slowDownloadLinksAlt = $('a[href*="slow_download"]').length;
|
||||
console.log(`[EbookScraper] Total links on page: ${allLinks}`);
|
||||
console.log(`[EbookScraper] Links with /slow_download/: ${slowDownloadLinks}`);
|
||||
console.log(`[EbookScraper] Links with slow_download (no slashes): ${slowDownloadLinksAlt}`);
|
||||
const allLinks = $('a').length;
|
||||
const slowDownloadLinks = $('a[href*="/slow_download/"]').length;
|
||||
const slowDownloadLinksAlt = $('a[href*="slow_download"]').length;
|
||||
moduleLogger.debug('Link counts on page', { allLinks, slowDownloadLinks, slowDownloadLinksAlt });
|
||||
|
||||
// Log all href patterns to see what we're dealing with
|
||||
const hrefPatterns: string[] = [];
|
||||
$('a[href]').each((i, elem) => {
|
||||
const href = $(elem).attr('href') || '';
|
||||
if (href.includes('download') || href.includes('slow')) {
|
||||
hrefPatterns.push(href.substring(0, 100));
|
||||
}
|
||||
});
|
||||
if (hrefPatterns.length > 0) {
|
||||
console.log(`[EbookScraper] Download-related hrefs found:`, hrefPatterns.slice(0, 10));
|
||||
// Log all href patterns to see what we're dealing with
|
||||
const hrefPatterns: string[] = [];
|
||||
$('a[href]').each((i, elem) => {
|
||||
const href = $(elem).attr('href') || '';
|
||||
if (href.includes('download') || href.includes('slow')) {
|
||||
hrefPatterns.push(href.substring(0, 100));
|
||||
}
|
||||
});
|
||||
if (hrefPatterns.length > 0) {
|
||||
moduleLogger.debug('Download-related hrefs found', { hrefs: hrefPatterns.slice(0, 10) });
|
||||
}
|
||||
|
||||
// Find all slow download links
|
||||
@@ -563,28 +535,21 @@ async function getSlowDownloadLinks(
|
||||
// e.g., <li><a>Slow Partner Server #5</a> (no waitlist, but can be very slow)</li>
|
||||
const parentText = $(elem).parent().text().toLowerCase();
|
||||
|
||||
if (DEBUG_ENABLED) {
|
||||
const href = $(elem).attr('href');
|
||||
console.log(`[EbookScraper] Found slow_download link: href="${href}", linkText="${linkText.substring(0, 30)}", parentText="${parentText.substring(0, 60)}"`);
|
||||
}
|
||||
const href = $(elem).attr('href');
|
||||
moduleLogger.debug('Found slow_download link', { href, linkText: linkText.substring(0, 30), parentText: parentText.substring(0, 60) });
|
||||
|
||||
// Check for "no waitlist" in either the link text or parent text
|
||||
if (linkText.includes('no waitlist') || parentText.includes('no waitlist')) {
|
||||
const href = $(elem).attr('href');
|
||||
if (href) {
|
||||
// Convert relative URL to absolute
|
||||
const fullUrl = href.startsWith('http') ? href : `${baseUrl}${href}`;
|
||||
slowLinks.push(fullUrl);
|
||||
if (DEBUG_ENABLED) {
|
||||
console.log(`[EbookScraper] Added slow link (no waitlist): ${fullUrl}`);
|
||||
}
|
||||
moduleLogger.debug(`Added slow link (no waitlist): ${fullUrl}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (DEBUG_ENABLED) {
|
||||
console.log(`[EbookScraper] Total slow links found: ${slowLinks.length}`);
|
||||
}
|
||||
moduleLogger.debug(`Total slow links found: ${slowLinks.length}`);
|
||||
|
||||
await delay(REQUEST_DELAY_MS);
|
||||
return slowLinks;
|
||||
@@ -592,9 +557,7 @@ async function getSlowDownloadLinks(
|
||||
await logger?.error(
|
||||
`Failed to get slow links: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
if (DEBUG_ENABLED) {
|
||||
console.log(`[EbookScraper] Error getting slow links:`, error);
|
||||
}
|
||||
moduleLogger.debug('Error getting slow links', { error: error instanceof Error ? error.message : String(error) });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import Queue, { Job as BullJob, JobOptions } from 'bull';
|
||||
import Redis from 'ioredis';
|
||||
import { prisma } from '../db';
|
||||
import { TorrentResult } from '../utils/ranking-algorithm';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('JobQueue');
|
||||
|
||||
export type JobType =
|
||||
| 'search_indexers'
|
||||
@@ -151,12 +154,12 @@ export class JobQueueService {
|
||||
*/
|
||||
private setupEventHandlers(): void {
|
||||
this.queue.on('completed', async (job: BullJob, result: any) => {
|
||||
console.log(`Job ${job.id} completed:`, result);
|
||||
logger.info(`Job ${job.id} completed`, { result });
|
||||
await this.updateJobInDatabase(job.id as string, 'completed', result);
|
||||
});
|
||||
|
||||
this.queue.on('failed', async (job: BullJob, error: Error) => {
|
||||
console.error(`Job ${job.id} failed:`, error.message);
|
||||
logger.error(`Job ${job.id} failed`, { error: error.message });
|
||||
await this.updateJobInDatabase(
|
||||
job.id as string,
|
||||
'failed',
|
||||
@@ -168,7 +171,7 @@ export class JobQueueService {
|
||||
// Handle permanent failures for specific job types after all retries exhausted
|
||||
if (job.name === 'monitor_download' && job.data) {
|
||||
const payload = job.data as MonitorDownloadPayload;
|
||||
console.error(`[MonitorDownload] Job permanently failed for request ${payload.requestId} after ${job.attemptsMade} attempts`);
|
||||
logger.error(`MonitorDownload job permanently failed for request ${payload.requestId} after ${job.attemptsMade} attempts`);
|
||||
|
||||
// Update request status to failed (only happens after all retries exhausted)
|
||||
try {
|
||||
@@ -192,13 +195,13 @@ export class JobQueueService {
|
||||
});
|
||||
}
|
||||
} catch (updateError) {
|
||||
console.error('[MonitorDownload] Failed to update request/download status:', updateError);
|
||||
logger.error('Failed to update request/download status', { error: updateError instanceof Error ? updateError.message : String(updateError) });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.queue.on('stalled', async (job: BullJob) => {
|
||||
console.warn(`Job ${job.id} stalled`);
|
||||
logger.warn(`Job ${job.id} stalled`);
|
||||
await this.updateJobInDatabase(job.id as string, 'stuck');
|
||||
});
|
||||
|
||||
@@ -207,7 +210,7 @@ export class JobQueueService {
|
||||
});
|
||||
|
||||
this.queue.on('error', (error: Error) => {
|
||||
console.error('Queue error:', error);
|
||||
logger.error('Queue error', { error: error.message });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -322,7 +325,7 @@ export class JobQueueService {
|
||||
where: { id: payload.scheduledJobId },
|
||||
data: { lastRun: new Date() },
|
||||
}).catch(err => {
|
||||
console.error(`[JobQueue] Failed to update lastRun for scheduled job ${payload.scheduledJobId}:`, err);
|
||||
logger.error(`Failed to update lastRun for scheduled job ${payload.scheduledJobId}`, { error: err instanceof Error ? err.message : String(err) });
|
||||
});
|
||||
}
|
||||
return { ...payload, jobId: existingJob.id };
|
||||
@@ -347,7 +350,7 @@ export class JobQueueService {
|
||||
where: { id: payload.scheduledJobId },
|
||||
data: { lastRun: new Date() },
|
||||
}).catch(err => {
|
||||
console.error(`[JobQueue] Failed to update lastRun for scheduled job ${payload.scheduledJobId}:`, err);
|
||||
logger.error(`Failed to update lastRun for scheduled job ${payload.scheduledJobId}`, { error: err instanceof Error ? err.message : String(err) });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -395,7 +398,7 @@ export class JobQueueService {
|
||||
data: updateData,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update job in database:', error);
|
||||
logger.error('Failed to update job in database', { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -801,7 +804,7 @@ export class JobQueueService {
|
||||
},
|
||||
jobId,
|
||||
});
|
||||
console.log(`[JobQueue] Added repeatable job: ${jobType} with cron ${cronExpression}`);
|
||||
logger.info(`Added repeatable job: ${jobType} with cron ${cronExpression}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -816,7 +819,7 @@ export class JobQueueService {
|
||||
cron: cronExpression,
|
||||
jobId,
|
||||
});
|
||||
console.log(`[JobQueue] Removed repeatable job: ${jobType}`);
|
||||
logger.info(`Removed repeatable job: ${jobType}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -840,7 +843,7 @@ export function getJobQueueService(): JobQueueService {
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
if (jobQueueService) {
|
||||
console.log('Closing job queue...');
|
||||
logger.info('Closing job queue...');
|
||||
await jobQueueService.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
} from './ILibraryService';
|
||||
import { getPlexService } from '@/lib/integrations/plex.service';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('PlexLibrary');
|
||||
|
||||
export class PlexLibraryService implements ILibraryService {
|
||||
private plexService = getPlexService();
|
||||
@@ -175,7 +178,7 @@ export class PlexLibraryService implements ILibraryService {
|
||||
// This is a simplified implementation
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('[PlexLibraryService] Failed to get item:', error);
|
||||
logger.error('Failed to get item', { error: error instanceof Error ? error.message : String(error) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
import { prisma } from '../db';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('RequestDelete');
|
||||
|
||||
export interface DeleteRequestResult {
|
||||
success: boolean;
|
||||
@@ -111,7 +114,7 @@ export async function deleteRequest(
|
||||
torrent = await qbt.getTorrent(downloadHistory.torrentHash);
|
||||
} catch (error) {
|
||||
// Torrent not found in qBittorrent (already removed)
|
||||
console.log(`[RequestDelete] Torrent ${downloadHistory.torrentHash} not found in qBittorrent, skipping`);
|
||||
logger.info(`Torrent ${downloadHistory.torrentHash} not found in qBittorrent, skipping`);
|
||||
}
|
||||
|
||||
if (torrent) {
|
||||
@@ -121,14 +124,14 @@ export async function deleteRequest(
|
||||
|
||||
if (isUnlimitedSeeding) {
|
||||
// Unlimited seeding - keep in qBittorrent, stop monitoring
|
||||
console.log(
|
||||
`[RequestDelete] Keeping torrent ${torrent.name} for unlimited seeding (indexer: ${downloadHistory.indexerName})`
|
||||
logger.info(
|
||||
`Keeping torrent ${torrent.name} for unlimited seeding (indexer: ${downloadHistory.indexerName})`
|
||||
);
|
||||
torrentsKeptUnlimited++;
|
||||
} else if (!isCompleted) {
|
||||
// Download not completed - delete immediately
|
||||
console.log(
|
||||
`[RequestDelete] Deleting incomplete download: ${torrent.name}`
|
||||
logger.info(
|
||||
`Deleting incomplete download: ${torrent.name}`
|
||||
);
|
||||
await qbt.deleteTorrent(downloadHistory.torrentHash, true);
|
||||
torrentsRemoved++;
|
||||
@@ -140,8 +143,8 @@ export async function deleteRequest(
|
||||
|
||||
if (hasMetRequirement) {
|
||||
// Seeding requirement met - delete now
|
||||
console.log(
|
||||
`[RequestDelete] Deleting torrent ${torrent.name} (seeding complete: ${Math.floor(
|
||||
logger.info(
|
||||
`Deleting torrent ${torrent.name} (seeding complete: ${Math.floor(
|
||||
actualSeedingTime / 60
|
||||
)}/${seedingConfig.seedingTimeMinutes} minutes)`
|
||||
);
|
||||
@@ -150,8 +153,8 @@ export async function deleteRequest(
|
||||
} else {
|
||||
// Still needs seeding - keep for cleanup job
|
||||
const remainingMinutes = Math.ceil((seedingTimeSeconds - actualSeedingTime) / 60);
|
||||
console.log(
|
||||
`[RequestDelete] Keeping torrent ${torrent.name} for ${remainingMinutes} more minutes of seeding`
|
||||
logger.info(
|
||||
`Keeping torrent ${torrent.name} for ${remainingMinutes} more minutes of seeding`
|
||||
);
|
||||
torrentsKeptSeeding++;
|
||||
}
|
||||
@@ -165,17 +168,17 @@ export async function deleteRequest(
|
||||
|
||||
// Try to delete the NZB from SABnzbd (might already be completed/removed)
|
||||
await sabnzbd.deleteNZB(downloadHistory.nzbId, true);
|
||||
console.log(`[RequestDelete] Deleted NZB ${downloadHistory.nzbId} from SABnzbd`);
|
||||
logger.info(`Deleted NZB ${downloadHistory.nzbId} from SABnzbd`);
|
||||
torrentsRemoved++;
|
||||
} catch (error) {
|
||||
// NZB not found or already removed
|
||||
console.log(`[RequestDelete] NZB ${downloadHistory.nzbId} not found in SABnzbd, skipping`);
|
||||
logger.info(`NZB ${downloadHistory.nzbId} not found in SABnzbd, skipping`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[RequestDelete] Error handling download for request ${requestId}:`,
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error(
|
||||
`Error handling download for request ${requestId}`,
|
||||
{ error: error instanceof Error ? error.message : String(error) }
|
||||
);
|
||||
// Continue with deletion even if download handling fails
|
||||
}
|
||||
@@ -229,7 +232,7 @@ export async function deleteRequest(
|
||||
// Delete the title folder (not the author folder)
|
||||
await fs.rm(titleFolderPath, { recursive: true, force: true });
|
||||
|
||||
console.log(`[RequestDelete] Deleted media directory: ${titleFolderPath}`);
|
||||
logger.info(`Deleted media directory: ${titleFolderPath}`);
|
||||
filesDeleted = true;
|
||||
} catch (accessError) {
|
||||
// Folder doesn't exist - try without year/ASIN (fallback for older files)
|
||||
@@ -237,20 +240,20 @@ export async function deleteRequest(
|
||||
try {
|
||||
await fs.access(fallbackPath);
|
||||
await fs.rm(fallbackPath, { recursive: true, force: true });
|
||||
console.log(`[RequestDelete] Deleted media directory (fallback path): ${fallbackPath}`);
|
||||
logger.info(`Deleted media directory (fallback path): ${fallbackPath}`);
|
||||
filesDeleted = true;
|
||||
} catch (fallbackError) {
|
||||
// Neither path exists - that's okay
|
||||
console.log(
|
||||
`[RequestDelete] Media directory not found (tried: ${titleFolderPath}, ${fallbackPath})`
|
||||
logger.info(
|
||||
`Media directory not found (tried: ${titleFolderPath}, ${fallbackPath})`
|
||||
);
|
||||
filesDeleted = false;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[RequestDelete] Error deleting media files for request ${requestId}:`,
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error(
|
||||
`Error deleting media files for request ${requestId}`,
|
||||
{ error: error instanceof Error ? error.message : String(error) }
|
||||
);
|
||||
// Continue with soft delete even if file deletion fails
|
||||
}
|
||||
@@ -291,18 +294,18 @@ export async function deleteRequest(
|
||||
|
||||
await Promise.all(deletePromises);
|
||||
|
||||
console.log(
|
||||
`[RequestDelete] Deleted ${exactMatches.length} plex_library record(s) for "${request.audiobook.title}"`
|
||||
logger.info(
|
||||
`Deleted ${exactMatches.length} plex_library record(s) for "${request.audiobook.title}"`
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`[RequestDelete] No plex_library records found for "${request.audiobook.title}"`
|
||||
logger.info(
|
||||
`No plex_library records found for "${request.audiobook.title}"`
|
||||
);
|
||||
}
|
||||
} catch (libError) {
|
||||
console.error(
|
||||
`[RequestDelete] Error deleting plex_library records:`,
|
||||
libError instanceof Error ? libError.message : 'Unknown error'
|
||||
logger.error(
|
||||
`Error deleting plex_library records`,
|
||||
{ error: libError instanceof Error ? libError.message : String(libError) }
|
||||
);
|
||||
// Continue with deletion even if library cleanup fails
|
||||
}
|
||||
@@ -325,13 +328,13 @@ export async function deleteRequest(
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[RequestDelete] Cleared availability status for audiobook ${request.audiobook.id}`
|
||||
logger.info(
|
||||
`Cleared availability status for audiobook ${request.audiobook.id}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[RequestDelete] Error clearing audiobook status:`,
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error(
|
||||
`Error clearing audiobook status`,
|
||||
{ error: error instanceof Error ? error.message : String(error) }
|
||||
);
|
||||
// Continue with deletion even if this fails
|
||||
}
|
||||
@@ -345,8 +348,8 @@ export async function deleteRequest(
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[RequestDelete] Request ${requestId} soft-deleted by admin ${adminUserId}`
|
||||
logger.info(
|
||||
`Request ${requestId} soft-deleted by admin ${adminUserId}`
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -358,9 +361,9 @@ export async function deleteRequest(
|
||||
torrentsKeptUnlimited,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[RequestDelete] Failed to delete request ${requestId}:`,
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error(
|
||||
`Failed to delete request ${requestId}`,
|
||||
{ error: error instanceof Error ? error.message : String(error) }
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
|
||||
import { getJobQueueService, ScanPlexPayload } from './job-queue.service';
|
||||
import { prisma } from '../db';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('Scheduler');
|
||||
|
||||
export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds';
|
||||
|
||||
@@ -44,7 +47,7 @@ export class SchedulerService {
|
||||
* Initialize scheduler and set up default jobs if they don't exist
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
console.log('[Scheduler] Initializing scheduler service...');
|
||||
logger.info('Initializing scheduler service...');
|
||||
|
||||
// Create default jobs if they don't exist
|
||||
await this.ensureDefaultJobs();
|
||||
@@ -55,7 +58,7 @@ export class SchedulerService {
|
||||
// Check and trigger overdue jobs
|
||||
await this.triggerOverdueJobs();
|
||||
|
||||
console.log('[Scheduler] Scheduler service started');
|
||||
logger.info('Scheduler service started');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,7 +126,7 @@ export class SchedulerService {
|
||||
await prisma.scheduledJob.create({
|
||||
data: defaultJob,
|
||||
});
|
||||
console.log(`[Scheduler] Created default job: ${defaultJob.name} (disabled by default)`);
|
||||
logger.info(`Created default job: ${defaultJob.name} (disabled by default)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,7 +143,7 @@ export class SchedulerService {
|
||||
await this.scheduleJob(job);
|
||||
}
|
||||
|
||||
console.log(`[Scheduler] Scheduled ${jobs.length} jobs`);
|
||||
logger.info(`Scheduled ${jobs.length} jobs`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,9 +157,9 @@ export class SchedulerService {
|
||||
job.schedule,
|
||||
`scheduled-${job.id}`
|
||||
);
|
||||
console.log(`[Scheduler] Job scheduled: ${job.name} (${job.schedule})`);
|
||||
logger.info(`Job scheduled: ${job.name} (${job.schedule})`);
|
||||
} catch (error) {
|
||||
console.error(`[Scheduler] Failed to schedule job ${job.name}:`, error);
|
||||
logger.error(`Failed to schedule job ${job.name}`, { error: error instanceof Error ? error.message : String(error) });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -171,9 +174,9 @@ export class SchedulerService {
|
||||
job.schedule,
|
||||
`scheduled-${job.id}`
|
||||
);
|
||||
console.log(`[Scheduler] Job unscheduled: ${job.name}`);
|
||||
logger.info(`Job unscheduled: ${job.name}`);
|
||||
} catch (error) {
|
||||
console.error(`[Scheduler] Failed to unschedule job ${job.name}:`, error);
|
||||
logger.error(`Failed to unschedule job ${job.name}`, { error: error instanceof Error ? error.message : String(error) });
|
||||
// Don't throw - job might not exist in Bull yet
|
||||
}
|
||||
}
|
||||
@@ -324,7 +327,7 @@ export class SchedulerService {
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[Scheduler] Job "${job.name}" triggered with Bull job ID: ${bullJobId}`);
|
||||
logger.info(`Job "${job.name}" triggered with Bull job ID: ${bullJobId}`);
|
||||
|
||||
return bullJobId;
|
||||
}
|
||||
@@ -362,7 +365,7 @@ export class SchedulerService {
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
const errorMsg = `Audiobookshelf is not configured. Missing: ${missingFields.join(', ')}. Please configure Audiobookshelf in the admin settings before running library scans.`;
|
||||
console.error('[ScanLibrary] Error:', errorMsg);
|
||||
logger.error(errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
@@ -386,14 +389,14 @@ export class SchedulerService {
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
const errorMsg = `Plex is not configured. Missing: ${missingFields.join(', ')}. Please configure Plex in the admin settings before running library scans.`;
|
||||
console.error('[ScanLibrary] Error:', errorMsg);
|
||||
logger.error(errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
libraryId = job.payload?.libraryId || plexConfig.plex_audiobook_library_id;
|
||||
}
|
||||
|
||||
console.log(`[ScanLibrary] Triggering ${backendMode} library scan for library: ${libraryId}`);
|
||||
logger.info(`Triggering ${backendMode} library scan for library: ${libraryId}`);
|
||||
|
||||
return await this.jobQueue.addPlexScanJob(
|
||||
libraryId || '',
|
||||
@@ -438,7 +441,7 @@ export class SchedulerService {
|
||||
* Check for overdue jobs and trigger them
|
||||
*/
|
||||
private async triggerOverdueJobs(): Promise<void> {
|
||||
console.log('[Scheduler] Checking for overdue jobs...');
|
||||
logger.info('Checking for overdue jobs...');
|
||||
|
||||
const jobs = await prisma.scheduledJob.findMany({
|
||||
where: { enabled: true },
|
||||
@@ -447,11 +450,11 @@ export class SchedulerService {
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
if (this.isJobOverdue(job)) {
|
||||
console.log(`[Scheduler] Job "${job.name}" is overdue, triggering now...`);
|
||||
logger.info(`Job "${job.name}" is overdue, triggering now...`);
|
||||
await this.triggerJobNow(job.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Scheduler] Failed to trigger overdue job "${job.name}":`, error);
|
||||
logger.error(`Failed to trigger overdue job "${job.name}"`, { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -468,7 +471,7 @@ export class SchedulerService {
|
||||
// Parse cron expression to get interval in milliseconds
|
||||
const intervalMs = this.getIntervalFromCron(job.schedule);
|
||||
if (!intervalMs) {
|
||||
console.warn(`[Scheduler] Could not parse interval for job "${job.name}", skipping`);
|
||||
logger.warn(`Could not parse interval for job "${job.name}", skipping`);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -530,7 +533,7 @@ export class SchedulerService {
|
||||
}
|
||||
|
||||
// For other patterns, return a conservative default (24 hours)
|
||||
console.warn(`[Scheduler] Unknown cron pattern "${cronExpression}", defaulting to 24 hours`);
|
||||
logger.warn(`Unknown cron pattern "${cronExpression}", defaulting to 24 hours`);
|
||||
return 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import axios from 'axios';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('ThumbnailCache');
|
||||
|
||||
const CACHE_DIR = '/app/cache/thumbnails';
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB max per image
|
||||
@@ -20,7 +23,7 @@ export class ThumbnailCacheService {
|
||||
try {
|
||||
await fs.mkdir(CACHE_DIR, { recursive: true });
|
||||
} catch (error) {
|
||||
console.error('[ThumbnailCache] Failed to create cache directory:', error);
|
||||
logger.error('Failed to create cache directory', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -79,18 +82,18 @@ export class ThumbnailCacheService {
|
||||
// Verify content type is an image
|
||||
const contentType = response.headers['content-type'];
|
||||
if (!contentType || !contentType.startsWith('image/')) {
|
||||
console.warn(`[ThumbnailCache] Invalid content type for ${asin}: ${contentType}`);
|
||||
logger.warn(`Invalid content type for ${asin}: ${contentType}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Write to file
|
||||
await fs.writeFile(filePath, Buffer.from(response.data));
|
||||
|
||||
console.log(`[ThumbnailCache] Cached thumbnail for ${asin}: ${filePath}`);
|
||||
logger.info(`Cached thumbnail for ${asin}: ${filePath}`);
|
||||
return filePath;
|
||||
} catch (error) {
|
||||
// Log error but don't throw - we'll fall back to the original URL
|
||||
console.error(`[ThumbnailCache] Failed to cache thumbnail for ${asin}:`, error);
|
||||
logger.error(`Failed to cache thumbnail for ${asin}`, { error: error instanceof Error ? error.message : String(error) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -108,10 +111,10 @@ export class ThumbnailCacheService {
|
||||
for (const file of asinFiles) {
|
||||
const filePath = path.join(CACHE_DIR, file);
|
||||
await fs.unlink(filePath);
|
||||
console.log(`[ThumbnailCache] Deleted thumbnail: ${filePath}`);
|
||||
logger.info(`Deleted thumbnail: ${filePath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[ThumbnailCache] Failed to delete thumbnail for ${asin}:`, error);
|
||||
logger.error(`Failed to delete thumbnail for ${asin}`, { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,14 +138,14 @@ export class ThumbnailCacheService {
|
||||
const filePath = path.join(CACHE_DIR, file);
|
||||
await fs.unlink(filePath);
|
||||
deletedCount++;
|
||||
console.log(`[ThumbnailCache] Deleted unused thumbnail: ${file}`);
|
||||
logger.info(`Deleted unused thumbnail: ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[ThumbnailCache] Cleanup complete: ${deletedCount} thumbnails deleted`);
|
||||
logger.info(`Cleanup complete: ${deletedCount} thumbnails deleted`);
|
||||
return deletedCount;
|
||||
} catch (error) {
|
||||
console.error('[ThumbnailCache] Failed to cleanup thumbnails:', error);
|
||||
logger.error('Failed to cleanup thumbnails', { error: error instanceof Error ? error.message : String(error) });
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { compareTwoStrings } from 'string-similarity';
|
||||
import { LibraryItem } from '@/lib/services/library';
|
||||
import { RMABLogger } from './logger';
|
||||
|
||||
// Debug logging controlled by LOG_LEVEL environment variable
|
||||
const DEBUG_ENABLED = process.env.LOG_LEVEL === 'debug';
|
||||
// Module-level logger
|
||||
const logger = RMABLogger.create('AudiobookMatcher');
|
||||
|
||||
export interface AudiobookMatchInput {
|
||||
asin: string;
|
||||
@@ -109,7 +110,7 @@ export async function findPlexMatch(
|
||||
// If no candidates found, log and return null
|
||||
if (plexBooks.length === 0) {
|
||||
matchResult.matchType = 'no_candidates';
|
||||
if (DEBUG_ENABLED) console.log(JSON.stringify({ MATCHER: matchResult }));
|
||||
logger.debug('Matcher result', { MATCHER: matchResult });
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -125,7 +126,7 @@ export async function findPlexMatch(
|
||||
asin: plexBook.asin,
|
||||
confidence: 100,
|
||||
};
|
||||
if (DEBUG_ENABLED) console.log(JSON.stringify({ MATCHER: matchResult }));
|
||||
logger.debug('Matcher result', { MATCHER: matchResult });
|
||||
return plexBook;
|
||||
}
|
||||
}
|
||||
@@ -141,7 +142,7 @@ export async function findPlexMatch(
|
||||
plexAuthor: plexBook.author,
|
||||
confidence: 100,
|
||||
};
|
||||
if (DEBUG_ENABLED) console.log(JSON.stringify({ MATCHER: matchResult }));
|
||||
logger.debug('Matcher result', { MATCHER: matchResult });
|
||||
return plexBook;
|
||||
}
|
||||
}
|
||||
@@ -182,7 +183,7 @@ export async function findPlexMatch(
|
||||
|
||||
if (validCandidates.length === 0) {
|
||||
matchResult.matchType = 'asin_filtered_all';
|
||||
if (DEBUG_ENABLED) console.log(JSON.stringify({ MATCHER: matchResult }));
|
||||
logger.debug('Matcher result', { MATCHER: matchResult });
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -250,13 +251,13 @@ export async function findPlexMatch(
|
||||
plexAuthor: bestMatch.plexBook.author,
|
||||
confidence: Math.round(bestMatch.score * 100),
|
||||
};
|
||||
if (DEBUG_ENABLED) console.log(JSON.stringify({ MATCHER: matchResult }));
|
||||
logger.debug('Matcher result', { MATCHER: matchResult });
|
||||
return bestMatch.plexBook;
|
||||
}
|
||||
|
||||
// No match found
|
||||
matchResult.matchType = 'fuzzy_below_threshold';
|
||||
if (DEBUG_ENABLED) console.log(JSON.stringify({ MATCHER: matchResult }));
|
||||
logger.debug('Matcher result', { MATCHER: matchResult });
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -362,15 +363,12 @@ export async function enrichAudiobooksWithMatches(
|
||||
}
|
||||
}
|
||||
|
||||
if (DEBUG_ENABLED) {
|
||||
const summary = {
|
||||
total: results.length,
|
||||
available: results.filter(r => r.isAvailable).length,
|
||||
notAvailable: results.filter(r => !r.isAvailable).length,
|
||||
requested: userId ? results.filter(r => (r as any).isRequested).length : 'N/A',
|
||||
};
|
||||
console.log(JSON.stringify({ MATCHER_BATCH_SUMMARY: summary }));
|
||||
}
|
||||
logger.debug('Batch summary', {
|
||||
total: results.length,
|
||||
available: results.filter(r => r.isAvailable).length,
|
||||
notAvailable: results.filter(r => !r.isAvailable).length,
|
||||
requested: userId ? results.filter(r => (r as any).isRequested).length : 'N/A',
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -405,16 +403,12 @@ export function matchAudiobook(
|
||||
item.asin?.toLowerCase() === request.asin?.toLowerCase()
|
||||
);
|
||||
if (asinMatch) {
|
||||
if (DEBUG_ENABLED) {
|
||||
console.log(JSON.stringify({
|
||||
GENERIC_MATCHER: {
|
||||
matchType: 'asin_exact',
|
||||
input: { title: request.title, asin: request.asin },
|
||||
matched: { title: asinMatch.title, asin: asinMatch.asin },
|
||||
confidence: 100
|
||||
}
|
||||
}));
|
||||
}
|
||||
logger.debug('Generic matcher result', {
|
||||
matchType: 'asin_exact',
|
||||
input: { title: request.title, asin: request.asin },
|
||||
matched: { title: asinMatch.title, asin: asinMatch.asin },
|
||||
confidence: 100
|
||||
});
|
||||
return asinMatch;
|
||||
}
|
||||
}
|
||||
@@ -426,16 +420,12 @@ export function matchAudiobook(
|
||||
item.isbn && normalizeISBN(item.isbn) === normalizedRequestISBN
|
||||
);
|
||||
if (isbnMatch) {
|
||||
if (DEBUG_ENABLED) {
|
||||
console.log(JSON.stringify({
|
||||
GENERIC_MATCHER: {
|
||||
matchType: 'isbn_exact',
|
||||
input: { title: request.title, isbn: request.isbn },
|
||||
matched: { title: isbnMatch.title, isbn: isbnMatch.isbn },
|
||||
confidence: 95
|
||||
}
|
||||
}));
|
||||
}
|
||||
logger.debug('Generic matcher result', {
|
||||
matchType: 'isbn_exact',
|
||||
input: { title: request.title, isbn: request.isbn },
|
||||
matched: { title: isbnMatch.title, isbn: isbnMatch.isbn },
|
||||
confidence: 95
|
||||
});
|
||||
return isbnMatch;
|
||||
}
|
||||
}
|
||||
@@ -463,35 +453,27 @@ export function matchAudiobook(
|
||||
|
||||
// Accept if score >= 70%
|
||||
if (bestMatch && bestMatch.score >= 0.7) {
|
||||
if (DEBUG_ENABLED) {
|
||||
console.log(JSON.stringify({
|
||||
GENERIC_MATCHER: {
|
||||
matchType: 'fuzzy',
|
||||
input: { title: request.title, author: request.author },
|
||||
matched: { title: bestMatch.item.title, author: bestMatch.item.author },
|
||||
scores: {
|
||||
title: Math.round(bestMatch.titleScore * 100),
|
||||
author: Math.round(bestMatch.authorScore * 100),
|
||||
overall: Math.round(bestMatch.score * 100)
|
||||
},
|
||||
confidence: Math.round(bestMatch.score * 100)
|
||||
}
|
||||
}));
|
||||
}
|
||||
logger.debug('Generic matcher result', {
|
||||
matchType: 'fuzzy',
|
||||
input: { title: request.title, author: request.author },
|
||||
matched: { title: bestMatch.item.title, author: bestMatch.item.author },
|
||||
scores: {
|
||||
title: Math.round(bestMatch.titleScore * 100),
|
||||
author: Math.round(bestMatch.authorScore * 100),
|
||||
overall: Math.round(bestMatch.score * 100)
|
||||
},
|
||||
confidence: Math.round(bestMatch.score * 100)
|
||||
});
|
||||
return bestMatch.item;
|
||||
}
|
||||
|
||||
// No match found
|
||||
if (DEBUG_ENABLED) {
|
||||
console.log(JSON.stringify({
|
||||
GENERIC_MATCHER: {
|
||||
matchType: 'no_match',
|
||||
input: { title: request.title, author: request.author },
|
||||
bestScore: bestMatch ? Math.round(bestMatch.score * 100) : 0,
|
||||
threshold: 70
|
||||
}
|
||||
}));
|
||||
}
|
||||
logger.debug('Generic matcher result', {
|
||||
matchType: 'no_match',
|
||||
input: { title: request.title, author: request.author },
|
||||
bestScore: bestMatch ? Math.round(bestMatch.score * 100) : 0,
|
||||
threshold: 70
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ import path from 'path';
|
||||
import axios from 'axios';
|
||||
import { createJobLogger, JobLogger } from './job-logger';
|
||||
import { tagMultipleFiles, checkFfmpegAvailable } from './metadata-tagger';
|
||||
import { RMABLogger } from './logger';
|
||||
|
||||
const moduleLogger = RMABLogger.create('FileOrganizer');
|
||||
import {
|
||||
detectChapterFiles,
|
||||
analyzeChapterFiles,
|
||||
@@ -296,7 +299,7 @@ export class FileOrganizer {
|
||||
try {
|
||||
await fs.access(sourcePath, fs.constants.R_OK);
|
||||
} catch {
|
||||
console.warn(`[FileOrganizer] Source file not found or not readable: ${sourcePath}`);
|
||||
moduleLogger.warn(`Source file not found or not readable: ${sourcePath}`);
|
||||
result.errors.push(`Source file not found: ${audioFile}`);
|
||||
continue;
|
||||
}
|
||||
@@ -304,7 +307,7 @@ export class FileOrganizer {
|
||||
// Check if target already exists (skip if already copied)
|
||||
try {
|
||||
await fs.access(targetFilePath);
|
||||
console.log(`[FileOrganizer] File already exists, skipping: ${filename}`);
|
||||
moduleLogger.debug(`File already exists, skipping: ${filename}`);
|
||||
result.audioFiles.push(targetFilePath);
|
||||
|
||||
// Clean up tagged temp file if it exists
|
||||
@@ -504,7 +507,7 @@ export class FileOrganizer {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[FileOrganizer] Error reading directory:', error);
|
||||
moduleLogger.error('Error reading directory', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -532,7 +535,7 @@ export class FileOrganizer {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[FileOrganizer] Error reading directory ${dir}:`, error);
|
||||
moduleLogger.error(`Error reading directory ${dir}`, { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
|
||||
return files;
|
||||
@@ -601,7 +604,7 @@ export class FileOrganizer {
|
||||
// Copy from local cache instead of downloading
|
||||
await fs.copyFile(cachedPath, targetPath);
|
||||
await fs.chmod(targetPath, 0o644);
|
||||
console.log(`[FileOrganizer] Copied cover art from cache: ${filename}`);
|
||||
moduleLogger.debug(`Copied cover art from cache: ${filename}`);
|
||||
} else {
|
||||
// Download from external URL (e.g., Audible CDN)
|
||||
const response = await axios.get(url, {
|
||||
@@ -610,10 +613,10 @@ export class FileOrganizer {
|
||||
});
|
||||
|
||||
await fs.writeFile(targetPath, response.data);
|
||||
console.log(`[FileOrganizer] Downloaded cover art from URL`);
|
||||
moduleLogger.debug(`Downloaded cover art from URL`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[FileOrganizer] Failed to download cover art:', error);
|
||||
moduleLogger.error('Failed to download cover art', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -625,9 +628,9 @@ export class FileOrganizer {
|
||||
try {
|
||||
// Remove download directory and all remaining files
|
||||
await fs.rm(downloadPath, { recursive: true, force: true });
|
||||
console.log(`[FileOrganizer] Cleaned up: ${downloadPath}`);
|
||||
moduleLogger.debug(`Cleaned up: ${downloadPath}`);
|
||||
} catch (error) {
|
||||
console.error(`[FileOrganizer] Cleanup failed for ${downloadPath}:`, error);
|
||||
moduleLogger.error(`Cleanup failed for ${downloadPath}`, { error: error instanceof Error ? error.message : String(error) });
|
||||
// Don't throw - cleanup is non-critical
|
||||
}
|
||||
}
|
||||
|
||||
+26
-57
@@ -1,96 +1,65 @@
|
||||
/**
|
||||
* Component: Job Logger Utility
|
||||
* Component: Job Logger Utility (Backward Compatibility)
|
||||
* Documentation: documentation/backend/services/jobs.md
|
||||
*
|
||||
* Provides structured logging for job processors with database persistence
|
||||
* @deprecated Use RMABLogger.forJob() directly for new code.
|
||||
* This file provides backward compatibility for existing processors.
|
||||
*
|
||||
* Migration example:
|
||||
* ```typescript
|
||||
* // Before (deprecated)
|
||||
* const logger = jobId ? createJobLogger(jobId, 'Context') : null;
|
||||
* await logger?.info('message');
|
||||
*
|
||||
* // After (preferred)
|
||||
* import { RMABLogger } from './logger';
|
||||
* const logger = RMABLogger.forJob(jobId, 'Context');
|
||||
* logger.info('message'); // No await needed!
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { prisma } from '../db';
|
||||
import { RMABLogger, LogMetadata } from './logger';
|
||||
|
||||
export type LogLevel = 'info' | 'warn' | 'error';
|
||||
|
||||
export interface LogMetadata {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Job Logger - Logs events to both console and database
|
||||
* @deprecated Use RMABLogger.forJob() directly
|
||||
*/
|
||||
export class JobLogger {
|
||||
private jobId: string;
|
||||
private context: string;
|
||||
private logger: RMABLogger;
|
||||
|
||||
constructor(jobId: string, context: string) {
|
||||
this.jobId = jobId;
|
||||
this.context = context;
|
||||
this.logger = RMABLogger.forJob(jobId, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info message
|
||||
* @deprecated Returns Promise for backward compat but is actually synchronous
|
||||
*/
|
||||
async info(message: string, metadata?: LogMetadata): Promise<void> {
|
||||
await this.log('info', message, metadata);
|
||||
this.logger.info(message, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log warning message
|
||||
* @deprecated Returns Promise for backward compat but is actually synchronous
|
||||
*/
|
||||
async warn(message: string, metadata?: LogMetadata): Promise<void> {
|
||||
await this.log('warn', message, metadata);
|
||||
this.logger.warn(message, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error message
|
||||
* @deprecated Returns Promise for backward compat but is actually synchronous
|
||||
*/
|
||||
async error(message: string, metadata?: LogMetadata): Promise<void> {
|
||||
await this.log('error', message, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal logging method
|
||||
*/
|
||||
private async log(level: LogLevel, message: string, metadata?: LogMetadata): Promise<void> {
|
||||
// Log to console with timestamp (for Docker logs)
|
||||
const timestamp = new Date().toISOString().split('T')[1].split('.')[0];
|
||||
const consoleMessage = `[${this.context}] ${message}`;
|
||||
|
||||
switch (level) {
|
||||
case 'info':
|
||||
console.log(consoleMessage);
|
||||
break;
|
||||
case 'warn':
|
||||
console.warn(consoleMessage);
|
||||
break;
|
||||
case 'error':
|
||||
console.error(consoleMessage);
|
||||
break;
|
||||
}
|
||||
|
||||
// Log metadata if provided
|
||||
if (metadata && Object.keys(metadata).length > 0) {
|
||||
console.log(timestamp, JSON.stringify(metadata, null, 2));
|
||||
}
|
||||
|
||||
// Persist to database (non-blocking, ignore errors to not break job execution)
|
||||
try {
|
||||
await prisma.jobEvent.create({
|
||||
data: {
|
||||
jobId: this.jobId,
|
||||
level,
|
||||
context: this.context,
|
||||
message,
|
||||
metadata: metadata ? JSON.parse(JSON.stringify(metadata)) : null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[JobLogger] Failed to persist log to database:', error);
|
||||
// Don't throw - logging failure should not break job execution
|
||||
}
|
||||
this.logger.error(message, metadata);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a job logger instance
|
||||
* @deprecated Use RMABLogger.forJob() directly
|
||||
*/
|
||||
export function createJobLogger(jobId: string, context: string): JobLogger {
|
||||
return new JobLogger(jobId, context);
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
* Documentation: documentation/frontend/routing-auth.md
|
||||
*/
|
||||
|
||||
import { RMABLogger } from './logger';
|
||||
|
||||
const logger = RMABLogger.create('JWTClient');
|
||||
|
||||
interface JWTPayload {
|
||||
sub: string;
|
||||
plexId: string;
|
||||
@@ -27,7 +31,7 @@ export function decodeJWT(token: string): JWTPayload | null {
|
||||
const decoded = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
|
||||
return decoded as JWTPayload;
|
||||
} catch (error) {
|
||||
console.error('Failed to decode JWT:', error);
|
||||
logger.error('Failed to decode JWT', { error: error instanceof Error ? error.message : String(error) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
*/
|
||||
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { RMABLogger } from './logger';
|
||||
|
||||
const logger = RMABLogger.create('JWT');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'change-this-to-a-random-secret-key';
|
||||
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'change-this-to-another-random-secret-key';
|
||||
@@ -54,10 +57,7 @@ export function verifyAccessToken(token: string): TokenPayload | null {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as TokenPayload;
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
console.error('[JWT] Access token verification failed:', error);
|
||||
if (error instanceof Error) {
|
||||
console.error('[JWT] Error details:', error.message);
|
||||
}
|
||||
logger.error('Access token verification failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ export function verifyRefreshToken(token: string): RefreshTokenPayload | null {
|
||||
}
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
console.error('Refresh token verification failed:', error);
|
||||
logger.error('Refresh token verification failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Component: Centralized Logging System (RMABLogger)
|
||||
* Documentation: documentation/backend/services/logging.md
|
||||
*
|
||||
* Single logging infrastructure for all console and database logging.
|
||||
* All logs in the application should go through RMABLogger.
|
||||
*/
|
||||
|
||||
import { prisma } from '../db';
|
||||
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'quiet';
|
||||
|
||||
export interface LogMetadata {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// Log level hierarchy (lower number = more verbose)
|
||||
const LEVEL_PRIORITY: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
quiet: 4,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get configured log level from environment (single source of truth)
|
||||
*/
|
||||
function getConfiguredLogLevel(): LogLevel {
|
||||
const envLevel = process.env.LOG_LEVEL?.toLowerCase();
|
||||
if (envLevel && envLevel in LEVEL_PRIORITY) {
|
||||
return envLevel as LogLevel;
|
||||
}
|
||||
return 'info'; // Default
|
||||
}
|
||||
|
||||
// Cached log level (computed once at module load)
|
||||
const CONFIGURED_LOG_LEVEL = getConfiguredLogLevel();
|
||||
const CONFIGURED_LOG_PRIORITY = LEVEL_PRIORITY[CONFIGURED_LOG_LEVEL];
|
||||
|
||||
/**
|
||||
* RMABLogger - Centralized Logger for ReadMeABook
|
||||
*
|
||||
* Features:
|
||||
* - Context namespacing (e.g., RMABLogger.create('QBittorrent'))
|
||||
* - Job-aware database persistence (e.g., RMABLogger.forJob(jobId, 'Context'))
|
||||
* - Single LOG_LEVEL env var check point
|
||||
* - Consistent formatting: [LEVEL] [Context] Message
|
||||
* - Synchronous API - no await needed
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* // Standard logging
|
||||
* const logger = RMABLogger.create('QBittorrent');
|
||||
* logger.info('Connected successfully');
|
||||
* logger.debug('Cookie value', { cookie: '...' });
|
||||
*
|
||||
* // Job-aware logging (persists to database)
|
||||
* const logger = RMABLogger.forJob(jobId, 'SearchIndexers');
|
||||
* logger.info('Processing request'); // Logs to console AND database
|
||||
* ```
|
||||
*/
|
||||
export class RMABLogger {
|
||||
private context: string;
|
||||
private jobId: string | undefined;
|
||||
|
||||
private constructor(context: string, jobId?: string) {
|
||||
this.context = context;
|
||||
this.jobId = jobId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new logger with context namespace
|
||||
* @param context - Logger context (e.g., 'QBittorrent', 'Plex', 'API.Auth')
|
||||
*/
|
||||
static create(context: string): RMABLogger {
|
||||
return new RMABLogger(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a job-aware logger that persists to database
|
||||
* @param jobId - Job ID for database persistence (if undefined, logs to console only)
|
||||
* @param context - Logger context (e.g., 'SearchIndexers', 'MonitorDownload')
|
||||
*/
|
||||
static forJob(jobId: string | undefined, context: string): RMABLogger {
|
||||
return new RMABLogger(context, jobId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a child logger with extended context
|
||||
* @param subContext - Additional context to append
|
||||
*/
|
||||
child(subContext: string): RMABLogger {
|
||||
return new RMABLogger(`${this.context}.${subContext}`, this.jobId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug level logging (most verbose)
|
||||
* Only logged when LOG_LEVEL=debug
|
||||
* Never persisted to database
|
||||
*/
|
||||
debug(message: string, metadata?: LogMetadata): void {
|
||||
this.log('debug', message, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Info level logging (default level)
|
||||
* Logged unless LOG_LEVEL=warn, error, or quiet
|
||||
*/
|
||||
info(message: string, metadata?: LogMetadata): void {
|
||||
this.log('info', message, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Warning level logging
|
||||
* Logged unless LOG_LEVEL=error or quiet
|
||||
*/
|
||||
warn(message: string, metadata?: LogMetadata): void {
|
||||
this.log('warn', message, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Error level logging
|
||||
* Always logged unless LOG_LEVEL=quiet
|
||||
*/
|
||||
error(message: string, metadata?: LogMetadata): void {
|
||||
this.log('error', message, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal logging method - single point of LOG_LEVEL checking
|
||||
*/
|
||||
private log(
|
||||
level: Exclude<LogLevel, 'quiet'>,
|
||||
message: string,
|
||||
metadata?: LogMetadata
|
||||
): void {
|
||||
const levelPriority = LEVEL_PRIORITY[level];
|
||||
|
||||
// Check if this level should be logged (single check point)
|
||||
if (levelPriority < CONFIGURED_LOG_PRIORITY) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Format: [LEVEL] [Context] Message
|
||||
const formattedMessage = `[${level.toUpperCase()}] [${this.context}] ${message}`;
|
||||
|
||||
// Console output using appropriate method
|
||||
switch (level) {
|
||||
case 'debug':
|
||||
console.debug(formattedMessage);
|
||||
break;
|
||||
case 'info':
|
||||
console.log(formattedMessage);
|
||||
break;
|
||||
case 'warn':
|
||||
console.warn(formattedMessage);
|
||||
break;
|
||||
case 'error':
|
||||
console.error(formattedMessage);
|
||||
break;
|
||||
}
|
||||
|
||||
// Log metadata if provided
|
||||
if (metadata && Object.keys(metadata).length > 0) {
|
||||
console.log(JSON.stringify(metadata, null, 2));
|
||||
}
|
||||
|
||||
// Persist to database for job-aware loggers (fire-and-forget)
|
||||
// Debug logs are NEVER persisted to keep job_events clean
|
||||
if (this.jobId && level !== 'debug') {
|
||||
this.persistToDatabase(level, message, metadata);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist log to database (non-blocking, fire-and-forget)
|
||||
* Errors are silently caught - logging should never break job execution
|
||||
*/
|
||||
private persistToDatabase(
|
||||
level: Exclude<LogLevel, 'quiet' | 'debug'>,
|
||||
message: string,
|
||||
metadata?: LogMetadata
|
||||
): void {
|
||||
prisma.jobEvent
|
||||
.create({
|
||||
data: {
|
||||
jobId: this.jobId!,
|
||||
level,
|
||||
context: this.context,
|
||||
message,
|
||||
metadata: metadata ? JSON.parse(JSON.stringify(metadata)) : null,
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently fail - logging should never break job execution
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to get the current log level
|
||||
*/
|
||||
export function getLogLevel(): LogLevel {
|
||||
return CONFIGURED_LOG_LEVEL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if debug logging is enabled
|
||||
*/
|
||||
export function isDebugEnabled(): boolean {
|
||||
return CONFIGURED_LOG_LEVEL === 'debug';
|
||||
}
|
||||
@@ -7,6 +7,9 @@
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { RMABLogger } from './logger';
|
||||
|
||||
const logger = RMABLogger.create('PathMapper');
|
||||
|
||||
export interface PathMappingConfig {
|
||||
enabled: boolean;
|
||||
@@ -35,7 +38,7 @@ export class PathMapper {
|
||||
|
||||
// 2. Handle empty paths
|
||||
if (!qbittorrentPath || !config.remotePath || !config.localPath) {
|
||||
console.warn('PathMapper: Empty path or config, returning original');
|
||||
logger.warn('Empty path or config, returning original');
|
||||
return qbittorrentPath;
|
||||
}
|
||||
|
||||
@@ -47,8 +50,8 @@ export class PathMapper {
|
||||
|
||||
// 4. Check if qBittorrent path starts with remote path
|
||||
if (!normalizedQbPath.startsWith(normalizedRemote)) {
|
||||
console.warn(
|
||||
`PathMapper: Path "${qbittorrentPath}" does not start with remote path "${config.remotePath}". ` +
|
||||
logger.warn(
|
||||
`Path "${qbittorrentPath}" does not start with remote path "${config.remotePath}". ` +
|
||||
`Returning original path unchanged.`
|
||||
);
|
||||
return qbittorrentPath;
|
||||
@@ -60,7 +63,7 @@ export class PathMapper {
|
||||
// Join local path with relative path, ensuring proper path separators
|
||||
const transformedPath = path.join(normalizedLocal, relativePath);
|
||||
|
||||
console.log(`PathMapper: Transformed "${qbittorrentPath}" → "${transformedPath}"`);
|
||||
logger.info(`Transformed "${qbittorrentPath}" to "${transformedPath}"`);
|
||||
return transformedPath;
|
||||
}
|
||||
|
||||
@@ -95,7 +98,7 @@ export class PathMapper {
|
||||
|
||||
// Warn if paths look suspicious (but don't throw)
|
||||
if (config.remotePath === config.localPath) {
|
||||
console.warn('PathMapper: Remote and local paths are identical - path mapping will have no effect');
|
||||
logger.warn('Remote and local paths are identical - path mapping will have no effect');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-9
@@ -3,6 +3,10 @@
|
||||
* Documentation: documentation/backend/services/environment.md
|
||||
*/
|
||||
|
||||
import { RMABLogger } from './logger';
|
||||
|
||||
const logger = RMABLogger.create('URL');
|
||||
|
||||
/**
|
||||
* Get application base URL for OAuth callbacks and redirects
|
||||
*
|
||||
@@ -35,22 +39,20 @@ export function getBaseUrl(): string {
|
||||
|
||||
// Validate URL format
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
console.warn(`[URL Utility] Invalid base URL format: ${url}. URLs must start with http:// or https://`);
|
||||
logger.warn(`Invalid base URL format: ${url}. URLs must start with http:// or https://`);
|
||||
}
|
||||
|
||||
// Production warning if using localhost
|
||||
if (process.env.NODE_ENV === 'production' && url.includes('localhost')) {
|
||||
console.warn('[URL Utility] ⚠️ WARNING: Using localhost URL in production. OAuth callbacks may fail. Set PUBLIC_URL environment variable.');
|
||||
logger.warn('Using localhost URL in production. OAuth callbacks may fail. Set PUBLIC_URL environment variable.');
|
||||
}
|
||||
|
||||
// Log which variable is being used (debug only)
|
||||
if (process.env.LOG_LEVEL === 'debug') {
|
||||
const source = publicUrl ? 'PUBLIC_URL' :
|
||||
nextAuthUrl ? 'NEXTAUTH_URL' :
|
||||
baseUrl ? 'BASE_URL' :
|
||||
'default (localhost)';
|
||||
console.debug(`[URL Utility] Using base URL from ${source}: ${url}`);
|
||||
}
|
||||
const source = publicUrl ? 'PUBLIC_URL' :
|
||||
nextAuthUrl ? 'NEXTAUTH_URL' :
|
||||
baseUrl ? 'BASE_URL' :
|
||||
'default (localhost)';
|
||||
logger.debug(`Using base URL from ${source}: ${url}`);
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user