300 lines
8.8 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
|