Files
lidify/backend/src/utils/playlistLogger.ts
T
2025-12-25 18:58:06 -06:00

300 lines
8.8 KiB
TypeScript

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_<jobId>_<timestamp>.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);
}
}