cc8d0f6969
Major Features: - Multi-source download system (Soulseek/Lidarr with fallback) - Configurable enrichment speed control (1-5x) - Mobile touch drag support for seek sliders - iOS PWA media controls (Control Center, Lock Screen) - Artist name alias resolution via Last.fm - Circuit breaker pattern for audio analysis Critical Fixes: - Audio analyzer stability (non-ASCII, BrokenProcessPool, OOM) - Discovery system race conditions and import failures - Radio decade categorization using originalYear - LastFM API response normalization - Mood bucket infinite loop prevention Security: - Bull Board admin authentication - Lidarr webhook signature verification - JWT token expiration and refresh - Encryption key validation on startup Closes #2, #6, #9, #13, #21, #26, #31, #34, #35, #37, #40, #43
228 lines
6.1 KiB
TypeScript
228 lines
6.1 KiB
TypeScript
import * as fs from "fs";
|
||
import { logger } from "../utils/logger";
|
||
import * as path from "path";
|
||
|
||
/**
|
||
* Discovery Logger - Creates detailed log files for each discovery playlist generation
|
||
*/
|
||
class DiscoveryLogger {
|
||
private logDir: string;
|
||
private currentLogFile: string | null = null;
|
||
private currentStream: fs.WriteStream | null = null;
|
||
|
||
constructor() {
|
||
// Store logs in /app/logs/discovery (matches Dockerfile directory)
|
||
this.logDir = process.env.NODE_ENV === "production"
|
||
? "/app/logs/discovery"
|
||
: path.join(process.cwd(), "data", "logs", "discovery");
|
||
}
|
||
|
||
/**
|
||
* Start a new log file for a discovery generation
|
||
*/
|
||
start(userId: string, jobId?: number): string {
|
||
// Ensure log directory exists
|
||
if (!fs.existsSync(this.logDir)) {
|
||
fs.mkdirSync(this.logDir, { recursive: true });
|
||
}
|
||
|
||
// Create filename with timestamp
|
||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||
const filename = `discovery-${timestamp}-job${jobId || "manual"}.log`;
|
||
this.currentLogFile = path.join(this.logDir, filename);
|
||
|
||
// Open write stream
|
||
this.currentStream = fs.createWriteStream(this.currentLogFile, { flags: "a" });
|
||
|
||
// Write header
|
||
this.write("═".repeat(60));
|
||
this.write(`DISCOVERY WEEKLY GENERATION LOG`);
|
||
this.write(`Started: ${new Date().toISOString()}`);
|
||
this.write(`User ID: ${userId}`);
|
||
this.write(`Job ID: ${jobId || "manual"}`);
|
||
this.write("═".repeat(60));
|
||
this.write("");
|
||
|
||
return this.currentLogFile;
|
||
}
|
||
|
||
/**
|
||
* Write a line to the current log
|
||
*/
|
||
write(message: string, indent: number = 0): void {
|
||
const prefix = " ".repeat(indent);
|
||
const timestamp = new Date().toISOString().split("T")[1].split(".")[0];
|
||
const line = `[${timestamp}] ${prefix}${message}`;
|
||
|
||
// Write to file
|
||
if (this.currentStream) {
|
||
this.currentStream.write(line + "\n");
|
||
}
|
||
|
||
// Also write to console for real-time visibility
|
||
logger.debug(message);
|
||
}
|
||
|
||
/**
|
||
* Write a section header
|
||
*/
|
||
section(title: string): void {
|
||
this.write("");
|
||
this.write("─".repeat(50));
|
||
this.write(`> ${title}`);
|
||
this.write("─".repeat(50));
|
||
}
|
||
|
||
/**
|
||
* Write a success message
|
||
*/
|
||
success(message: string, indent: number = 0): void {
|
||
this.write(`✓ ${message}`, indent);
|
||
}
|
||
|
||
/**
|
||
* Write an error message
|
||
*/
|
||
error(message: string, indent: number = 0): void {
|
||
this.write(`✗ ${message}`, indent);
|
||
}
|
||
|
||
/**
|
||
* Write a warning message
|
||
*/
|
||
warn(message: string, indent: number = 0): void {
|
||
this.write(`[WARN] ${message}`, indent);
|
||
}
|
||
|
||
/**
|
||
* Write info message
|
||
*/
|
||
info(message: string, indent: number = 0): void {
|
||
this.write(`ℹ ${message}`, indent);
|
||
}
|
||
|
||
/**
|
||
* Write a table of key-value pairs
|
||
*/
|
||
table(data: Record<string, any>, indent: number = 1): void {
|
||
for (const [key, value] of Object.entries(data)) {
|
||
this.write(`${key}: ${value}`, indent);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Write a list of items
|
||
*/
|
||
list(items: string[], indent: number = 1): void {
|
||
for (const item of items) {
|
||
this.write(`• ${item}`, indent);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* End the current log and close the stream
|
||
*/
|
||
end(success: boolean, summary?: string): void {
|
||
this.write("");
|
||
this.write("═".repeat(60));
|
||
this.write(`GENERATION ${success ? "COMPLETED" : "FAILED"}`);
|
||
if (summary) {
|
||
this.write(summary);
|
||
}
|
||
this.write(`Ended: ${new Date().toISOString()}`);
|
||
this.write("═".repeat(60));
|
||
|
||
if (this.currentStream) {
|
||
this.currentStream.end();
|
||
this.currentStream = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get the path to the current log file
|
||
*/
|
||
getCurrentLogPath(): string | null {
|
||
return this.currentLogFile;
|
||
}
|
||
|
||
/**
|
||
* Get the most recent log file
|
||
*/
|
||
getLatestLog(): { path: string; content: string } | null {
|
||
if (!fs.existsSync(this.logDir)) {
|
||
return null;
|
||
}
|
||
|
||
const files = fs.readdirSync(this.logDir)
|
||
.filter(f => f.startsWith("discovery-") && f.endsWith(".log"))
|
||
.sort()
|
||
.reverse();
|
||
|
||
if (files.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
const latestPath = path.join(this.logDir, files[0]);
|
||
const content = fs.readFileSync(latestPath, "utf-8");
|
||
|
||
return { path: latestPath, content };
|
||
}
|
||
|
||
/**
|
||
* Get all log files (most recent first)
|
||
*/
|
||
getAllLogs(): { filename: string; date: Date; size: number }[] {
|
||
if (!fs.existsSync(this.logDir)) {
|
||
return [];
|
||
}
|
||
|
||
return fs.readdirSync(this.logDir)
|
||
.filter(f => f.startsWith("discovery-") && f.endsWith(".log"))
|
||
.map(filename => {
|
||
const filePath = path.join(this.logDir, filename);
|
||
const stats = fs.statSync(filePath);
|
||
return {
|
||
filename,
|
||
date: stats.mtime,
|
||
size: stats.size
|
||
};
|
||
})
|
||
.sort((a, b) => b.date.getTime() - a.date.getTime());
|
||
}
|
||
|
||
/**
|
||
* Get a specific log file content
|
||
*/
|
||
getLogContent(filename: string): string | null {
|
||
const filePath = path.join(this.logDir, filename);
|
||
if (!fs.existsSync(filePath)) {
|
||
return null;
|
||
}
|
||
return fs.readFileSync(filePath, "utf-8");
|
||
}
|
||
|
||
/**
|
||
* Clean up old logs (keep last N)
|
||
*/
|
||
cleanup(keepCount: number = 20): number {
|
||
const logs = this.getAllLogs();
|
||
let deleted = 0;
|
||
|
||
for (let i = keepCount; i < logs.length; i++) {
|
||
const filePath = path.join(this.logDir, logs[i].filename);
|
||
fs.unlinkSync(filePath);
|
||
deleted++;
|
||
}
|
||
|
||
return deleted;
|
||
}
|
||
}
|
||
|
||
export const discoveryLogger = new DiscoveryLogger();
|
||
|
||
|
||
|
||
|
||
|
||
|