import * as fs from 'fs'; import * as path from 'path'; /** * Dedicated logger for Spotify Import and Playlist operations. * * In Docker, the backend runs under /app and typically mounts /app/logs. * This logger defaults to writing under ./logs/playlists (relative to process.cwd()). * * Override with PLAYLIST_LOG_DIR if you want a different location. * * Log files: * - import__.log - Per-job detailed log * - session.log - Current session unified log (cleared on restart) * - events.log - Persistent event log */ const LOGS_DIR = process.env.PLAYLIST_LOG_DIR ? path.resolve(process.env.PLAYLIST_LOG_DIR) : path.join(process.cwd(), 'logs', 'playlists'); const SESSION_LOG = path.join(LOGS_DIR, 'session.log'); // Clear session log on module load (fresh start) let sessionInitialized = false; // Ensure logs directory exists function ensureLogsDir(): void { try { fs.mkdirSync(LOGS_DIR, { recursive: true }); } catch (error) { console.error('Failed to create playlist logs directory:', { logsDir: LOGS_DIR, error, }); } } // Initialize session log (clear previous session) function initSessionLog(): void { if (sessionInitialized) return; sessionInitialized = true; ensureLogsDir(); const header = ` ================================================================================ SPOTIFY IMPORT SESSION LOG Started: ${new Date().toISOString()} ================================================================================ `; try { fs.writeFileSync(SESSION_LOG, header); } catch (error) { console.error('Failed to initialize session log:', error); } } // Write to session log (unified log for all components) function writeToSessionLog(component: string, level: string, message: string): void { initSessionLog(); const timestamp = new Date().toISOString().split('T')[1].replace('Z', ''); const line = `[${timestamp}] [${component}] [${level}] ${message}\n`; try { fs.appendFileSync(SESSION_LOG, line); } catch (error) { // Silently fail - don't spam console } } /** * Get the path to the current session log */ export function getSessionLogPath(): string { ensureLogsDir(); return SESSION_LOG; } /** * Read the current session log contents */ export function readSessionLog(): string { try { if (fs.existsSync(SESSION_LOG)) { return fs.readFileSync(SESSION_LOG, 'utf-8'); } return 'No session log found'; } catch (error) { return `Error reading session log: ${error}`; } } /** * Log a message from any component to the unified session log * Use this for SLSKD, organize, etc. */ export function sessionLog(component: string, message: string, level: 'INFO' | 'WARN' | 'ERROR' | 'DEBUG' = 'INFO'): void { writeToSessionLog(component, level, message); // Also log to console with component prefix const prefix = `[${component}]`; if (level === 'ERROR') { console.error(prefix, message); } else { console.log(prefix, message); } } function getTimestamp(): string { return new Date().toISOString(); } function formatLogLine(level: string, message: string): string { return `[${getTimestamp()}] [${level}] ${message}\n`; } class PlaylistLogger { private jobId: string; private logFile: string; private buffer: string[] = []; constructor(jobId: string) { this.jobId = jobId; ensureLogsDir(); const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); this.logFile = path.join(LOGS_DIR, `import_${jobId}_${timestamp}.log`); // Write header this.write('INFO', `=== SPOTIFY IMPORT JOB: ${jobId} ===`); } private write(level: string, message: string): void { const line = formatLogLine(level, message); this.buffer.push(line); // Write to unified session log writeToSessionLog('IMPORT', level, message); // Also log to console if (level === 'ERROR') { console.error(`[Playlist Logger] ${message}`); } else { console.log(`[Playlist Logger] ${message}`); } // Flush to file this.flush(); } private flush(): void { try { fs.appendFileSync(this.logFile, this.buffer.join('')); this.buffer = []; } catch (error) { console.error(`[Playlist Logger] Failed to write to ${this.logFile}:`, error); } } info(message: string): void { this.write('INFO', message); } error(message: string): void { this.write('ERROR', message); } warn(message: string): void { this.write('WARN', message); } debug(message: string): void { this.write('DEBUG', message); } // Alias for info - used for generic logging log(message: string): void { this.write('DEBUG', message); } // Structured logging methods logJobStart(playlistName: string, trackCount: number, userId: string): void { this.info(`Playlist: "${playlistName}" (${trackCount} tracks)`); this.info(`User: ${userId}`); this.info(''); } logTrackMatchingStart(): void { this.info('--- TRACK MATCHING ---'); } logTrackMatch( index: number, total: number, title: string, artist: string, matched: boolean, matchedTrackId?: string ): void { const status = matched ? '✓' : '✗'; const result = matched ? `MATCHED -> ${matchedTrackId}` : 'NOT FOUND'; this.info(`[${index}/${total}] ${status} "${title}" by ${artist} - ${result}`); } logAlbumDownloadStart(count: number): void { this.info(''); this.info('--- ALBUM DOWNLOADS ---'); this.info(`Requesting ${count} album(s) from Lidarr`); } logAlbumQueued(albumName: string, artistName: string, mbid: string, lidarrId?: number): void { const lidarrInfo = lidarrId ? ` (Lidarr ID: ${lidarrId})` : ''; this.info(`✓ Queued: "${albumName}" by ${artistName} [MBID: ${mbid}]${lidarrInfo}`); } logAlbumFailed(albumName: string, artistName: string, reason: string): void { this.error(`✗ Failed: "${albumName}" by ${artistName} - ${reason}`); } logSlskdFallbackStart(albumName: string, artistName: string): void { this.info(''); this.info('--- SOULSEEK FALLBACK ---'); this.info(`Trying Soulseek for: "${albumName}" by ${artistName}`); } logSlskdSearchResult(found: boolean, quality?: string, username?: string, trackCount?: number, sizeMB?: number): void { if (found) { this.info(`✓ Soulseek match: ${quality} from ${username} (${trackCount} tracks, ${sizeMB}MB)`); } else { this.info(`✗ Soulseek: No suitable results found`); } } logSlskdDownloadQueued(filesQueued: number, username: string): void { this.info(`✓ Soulseek: Queued ${filesQueued} files from ${username}`); } logSlskdDownloadFailed(reason: string): void { this.error(`✗ Soulseek download failed: ${reason}`); } logDownloadProgress(completed: number, failed: number, pending: number): void { this.info(`Download status: ${completed} completed, ${failed} failed, ${pending} pending`); } logPlaylistCreationStart(): void { this.info(''); this.info('--- PLAYLIST CREATION ---'); } logPlaylistCreated(playlistId: string, trackCount: number, totalTracks: number): void { this.info(`Created playlist: ${playlistId}`); this.info(`Tracks added: ${trackCount}/${totalTracks}`); } logJobComplete(tracksMatched: number, tracksTotal: number, playlistId: string | null): void { this.info(''); this.info('=== JOB COMPLETE ==='); this.info(`Final result: ${tracksMatched}/${tracksTotal} tracks matched`); if (playlistId) { this.info(`Playlist ID: ${playlistId}`); } this.info(`Log file: ${this.logFile}`); } logJobFailed(error: string): void { this.info(''); this.error('=== JOB FAILED ==='); this.error(error); this.error(`Log file: ${this.logFile}`); } getLogFilePath(): string { return this.logFile; } } // Factory function to create loggers export function createPlaylistLogger(jobId: string): PlaylistLogger { return new PlaylistLogger(jobId); } // Quick console+file log for one-off messages export function logPlaylistEvent(message: string): void { ensureLogsDir(); const line = formatLogLine('INFO', message); const eventsFile = path.join(LOGS_DIR, 'events.log'); console.log(`[Playlist] ${message}`); try { fs.appendFileSync(eventsFile, line); } catch (error) { console.error(`Failed to write to events log:`, error); } }