Initial release v1.0.0
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user