Files
lidify/backend/src/routes/analysis.ts
T
Your Name cc8d0f6969 Release v1.3.0: Multi-source downloads, audio analyzer resilience, mobile improvements
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
2026-01-06 20:07:33 -06:00

360 lines
11 KiB
TypeScript

import { Router } from "express";
import { logger } from "../utils/logger";
import { prisma } from "../utils/db";
import { redisClient } from "../utils/redis";
import { requireAuth, requireAdmin } from "../middleware/auth";
import { getSystemSettings } from "../utils/systemSettings";
import os from "os";
const router = Router();
// Redis queue key for audio analysis
const ANALYSIS_QUEUE = "audio:analysis:queue";
/**
* GET /api/analysis/status
* Get audio analysis status and progress
*/
router.get("/status", requireAuth, async (req, res) => {
try {
// Get counts by status
const statusCounts = await prisma.track.groupBy({
by: ["analysisStatus"],
_count: true,
});
const total = statusCounts.reduce((sum, s) => sum + s._count, 0);
const completed = statusCounts.find(s => s.analysisStatus === "completed")?._count || 0;
const failed = statusCounts.find(s => s.analysisStatus === "failed")?._count || 0;
const processing = statusCounts.find(s => s.analysisStatus === "processing")?._count || 0;
const pending = statusCounts.find(s => s.analysisStatus === "pending")?._count || 0;
// Get queue length from Redis
const queueLength = await redisClient.lLen(ANALYSIS_QUEUE);
const progress = total > 0 ? Math.round((completed / total) * 100) : 0;
res.json({
total,
completed,
failed,
processing,
pending,
queueLength,
progress,
isComplete: pending === 0 && processing === 0 && queueLength === 0,
});
} catch (error: any) {
logger.error("Analysis status error:", error);
res.status(500).json({ error: "Failed to get analysis status" });
}
});
/**
* POST /api/analysis/start
* Start audio analysis for pending tracks (admin only)
*/
router.post("/start", requireAuth, requireAdmin, async (req, res) => {
try {
const { limit = 100, priority = "recent" } = req.body;
// Find pending tracks
const tracks = await prisma.track.findMany({
where: {
analysisStatus: "pending",
},
select: {
id: true,
filePath: true,
},
orderBy: priority === "recent"
? { fileModified: "desc" }
: { title: "asc" },
take: Math.min(limit, 1000),
});
if (tracks.length === 0) {
return res.json({
message: "No pending tracks to analyze",
queued: 0,
});
}
// Queue tracks for analysis
const pipeline = redisClient.multi();
for (const track of tracks) {
pipeline.rPush(ANALYSIS_QUEUE, JSON.stringify({
trackId: track.id,
filePath: track.filePath,
}));
}
await pipeline.exec();
logger.debug(`Queued ${tracks.length} tracks for audio analysis`);
res.json({
message: `Queued ${tracks.length} tracks for analysis`,
queued: tracks.length,
});
} catch (error: any) {
logger.error("Analysis start error:", error);
res.status(500).json({ error: "Failed to start analysis" });
}
});
/**
* POST /api/analysis/retry-failed
* Retry failed analysis jobs (admin only)
*/
router.post("/retry-failed", requireAuth, requireAdmin, async (req, res) => {
try {
// Reset failed tracks to pending
const result = await prisma.track.updateMany({
where: {
analysisStatus: "failed",
},
data: {
analysisStatus: "pending",
analysisError: null,
},
});
res.json({
message: `Reset ${result.count} failed tracks to pending`,
reset: result.count,
});
} catch (error: any) {
logger.error("Retry failed error:", error);
res.status(500).json({ error: "Failed to retry analysis" });
}
});
/**
* POST /api/analysis/analyze/:trackId
* Queue a specific track for analysis
*/
router.post("/analyze/:trackId", requireAuth, async (req, res) => {
try {
const { trackId } = req.params;
const track = await prisma.track.findUnique({
where: { id: trackId },
select: {
id: true,
filePath: true,
analysisStatus: true,
},
});
if (!track) {
return res.status(404).json({ error: "Track not found" });
}
// Queue for analysis
await redisClient.rPush(ANALYSIS_QUEUE, JSON.stringify({
trackId: track.id,
filePath: track.filePath,
}));
// Mark as pending if not already
if (track.analysisStatus !== "processing") {
await prisma.track.update({
where: { id: trackId },
data: { analysisStatus: "pending" },
});
}
res.json({
message: "Track queued for analysis",
trackId,
});
} catch (error: any) {
logger.error("Analyze track error:", error);
res.status(500).json({ error: "Failed to queue track for analysis" });
}
});
/**
* GET /api/analysis/track/:trackId
* Get analysis data for a specific track
*/
router.get("/track/:trackId", requireAuth, async (req, res) => {
try {
const { trackId } = req.params;
const track = await prisma.track.findUnique({
where: { id: trackId },
select: {
id: true,
title: true,
analysisStatus: true,
analysisError: true,
analyzedAt: true,
analysisVersion: true,
bpm: true,
beatsCount: true,
key: true,
keyScale: true,
keyStrength: true,
energy: true,
loudness: true,
dynamicRange: true,
danceability: true,
valence: true,
arousal: true,
instrumentalness: true,
acousticness: true,
speechiness: true,
moodTags: true,
essentiaGenres: true,
lastfmTags: true,
},
});
if (!track) {
return res.status(404).json({ error: "Track not found" });
}
res.json(track);
} catch (error: any) {
logger.error("Get track analysis error:", error);
res.status(500).json({ error: "Failed to get track analysis" });
}
});
/**
* GET /api/analysis/features
* Get aggregated feature statistics for the library
*/
router.get("/features", requireAuth, async (req, res) => {
try {
// Get analyzed tracks
const analyzed = await prisma.track.findMany({
where: {
analysisStatus: "completed",
bpm: { not: null },
},
select: {
bpm: true,
energy: true,
danceability: true,
valence: true,
keyScale: true,
},
});
if (analyzed.length === 0) {
return res.json({
count: 0,
averages: null,
distributions: null,
});
}
// Calculate averages
const avgBpm = analyzed.reduce((sum, t) => sum + (t.bpm || 0), 0) / analyzed.length;
const avgEnergy = analyzed.reduce((sum, t) => sum + (t.energy || 0), 0) / analyzed.length;
const avgDanceability = analyzed.reduce((sum, t) => sum + (t.danceability || 0), 0) / analyzed.length;
const avgValence = analyzed.reduce((sum, t) => sum + (t.valence || 0), 0) / analyzed.length;
// Key distribution
const majorCount = analyzed.filter(t => t.keyScale === "major").length;
const minorCount = analyzed.filter(t => t.keyScale === "minor").length;
// BPM distribution (buckets)
const bpmBuckets = {
slow: analyzed.filter(t => (t.bpm || 0) < 90).length,
moderate: analyzed.filter(t => (t.bpm || 0) >= 90 && (t.bpm || 0) < 120).length,
upbeat: analyzed.filter(t => (t.bpm || 0) >= 120 && (t.bpm || 0) < 150).length,
fast: analyzed.filter(t => (t.bpm || 0) >= 150).length,
};
res.json({
count: analyzed.length,
averages: {
bpm: Math.round(avgBpm),
energy: Math.round(avgEnergy * 100) / 100,
danceability: Math.round(avgDanceability * 100) / 100,
valence: Math.round(avgValence * 100) / 100,
},
distributions: {
key: { major: majorCount, minor: minorCount },
bpm: bpmBuckets,
},
});
} catch (error: any) {
logger.error("Get features error:", error);
res.status(500).json({ error: "Failed to get feature statistics" });
}
});
/**
* GET /api/analysis/workers
* Get current audio analyzer worker configuration
*/
router.get("/workers", requireAuth, requireAdmin, async (req, res) => {
try {
const settings = await getSystemSettings();
const cpuCores = os.cpus().length;
const currentWorkers = settings?.audioAnalyzerWorkers || 2;
// Recommended: 50% of CPU cores, min 2, max 8
const recommended = Math.max(2, Math.min(8, Math.floor(cpuCores / 2)));
res.json({
workers: currentWorkers,
cpuCores,
recommended,
description: `Using ${currentWorkers} of ${cpuCores} available CPU cores`,
});
} catch (error: any) {
logger.error("Get workers config error:", error);
res.status(500).json({ error: "Failed to get worker configuration" });
}
});
/**
* PUT /api/analysis/workers
* Update audio analyzer worker count
*/
router.put("/workers", requireAuth, requireAdmin, async (req, res) => {
try {
const { workers } = req.body;
if (typeof workers !== 'number' || workers < 1 || workers > 8) {
return res.status(400).json({
error: "Workers must be a number between 1 and 8"
});
}
// Update SystemSettings
await prisma.systemSettings.update({
where: { id: "default" },
data: { audioAnalyzerWorkers: workers },
});
// Publish control signal to Redis for Python worker to pick up
await redisClient.publish(
"audio:analysis:control",
JSON.stringify({ command: "set_workers", count: workers })
);
const cpuCores = os.cpus().length;
const recommended = Math.max(2, Math.min(8, Math.floor(cpuCores / 2)));
logger.info(`Audio analyzer workers updated to ${workers}`);
res.json({
workers,
cpuCores,
recommended,
description: `Using ${workers} of ${cpuCores} available CPU cores`,
});
} catch (error: any) {
logger.error("Update workers config error:", error);
res.status(500).json({ error: "Failed to update worker configuration" });
}
});
export default router;