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
This commit is contained in:
Your Name
2026-01-06 20:07:33 -06:00
parent 8fe151a0d1
commit cc8d0f6969
242 changed files with 20562 additions and 7725 deletions
+78 -12
View File
@@ -1,7 +1,10 @@
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();
@@ -42,7 +45,7 @@ router.get("/status", requireAuth, async (req, res) => {
isComplete: pending === 0 && processing === 0 && queueLength === 0,
});
} catch (error: any) {
console.error("Analysis status error:", error);
logger.error("Analysis status error:", error);
res.status(500).json({ error: "Failed to get analysis status" });
}
});
@@ -87,14 +90,14 @@ router.post("/start", requireAuth, requireAdmin, async (req, res) => {
}
await pipeline.exec();
console.log(`Queued ${tracks.length} tracks for audio analysis`);
logger.debug(`Queued ${tracks.length} tracks for audio analysis`);
res.json({
message: `Queued ${tracks.length} tracks for analysis`,
queued: tracks.length,
});
} catch (error: any) {
console.error("Analysis start error:", error);
logger.error("Analysis start error:", error);
res.status(500).json({ error: "Failed to start analysis" });
}
});
@@ -121,7 +124,7 @@ router.post("/retry-failed", requireAuth, requireAdmin, async (req, res) => {
reset: result.count,
});
} catch (error: any) {
console.error("Retry failed error:", error);
logger.error("Retry failed error:", error);
res.status(500).json({ error: "Failed to retry analysis" });
}
});
@@ -166,7 +169,7 @@ router.post("/analyze/:trackId", requireAuth, async (req, res) => {
trackId,
});
} catch (error: any) {
console.error("Analyze track error:", error);
logger.error("Analyze track error:", error);
res.status(500).json({ error: "Failed to queue track for analysis" });
}
});
@@ -214,7 +217,7 @@ router.get("/track/:trackId", requireAuth, async (req, res) => {
res.json(track);
} catch (error: any) {
console.error("Get track analysis error:", error);
logger.error("Get track analysis error:", error);
res.status(500).json({ error: "Failed to get track analysis" });
}
});
@@ -280,14 +283,77 @@ router.get("/features", requireAuth, async (req, res) => {
},
});
} catch (error: any) {
console.error("Get features error:", error);
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;
+6 -5
View File
@@ -1,4 +1,5 @@
import { Router } from "express";
import { logger } from "../utils/logger";
import { requireAuth } from "../middleware/auth";
import { prisma } from "../utils/db";
import crypto from "crypto";
@@ -88,7 +89,7 @@ router.post("/", async (req, res) => {
},
});
console.log(`API key created for user ${userId}: ${deviceName}`);
logger.debug(`API key created for user ${userId}: ${deviceName}`);
res.status(201).json({
apiKey: apiKey.key,
@@ -98,7 +99,7 @@ router.post("/", async (req, res) => {
"API key created successfully. Save this key - you won't see it again!",
});
} catch (error) {
console.error("Create API key error:", error);
logger.error("Create API key error:", error);
res.status(500).json({ error: "Failed to create API key" });
}
});
@@ -152,7 +153,7 @@ router.get("/", async (req, res) => {
res.json({ apiKeys: keys });
} catch (error) {
console.error("List API keys error:", error);
logger.error("List API keys error:", error);
res.status(500).json({ error: "Failed to list API keys" });
}
});
@@ -219,11 +220,11 @@ router.delete("/:id", async (req, res) => {
.json({ error: "API key not found or already deleted" });
}
console.log(`API key ${keyId} revoked by user ${userId}`);
logger.debug(`API key ${keyId} revoked by user ${userId}`);
res.json({ message: "API key revoked successfully" });
} catch (error) {
console.error("Delete API key error:", error);
logger.error("Delete API key error:", error);
res.status(500).json({ error: "Failed to revoke API key" });
}
});
+27 -26
View File
@@ -1,4 +1,5 @@
import { Router } from "express";
import { logger } from "../utils/logger";
import { lastFmService } from "../services/lastfm";
import { musicBrainzService } from "../services/musicbrainz";
import { fanartService } from "../services/fanart";
@@ -17,7 +18,7 @@ router.get("/preview/:artistName/:trackTitle", async (req, res) => {
const decodedArtist = decodeURIComponent(artistName);
const decodedTrack = decodeURIComponent(trackTitle);
console.log(
logger.debug(
`Getting preview for "${decodedTrack}" by ${decodedArtist}`
);
@@ -32,7 +33,7 @@ router.get("/preview/:artistName/:trackTitle", async (req, res) => {
res.status(404).json({ error: "Preview not found" });
}
} catch (error: any) {
console.error("Preview fetch error:", error);
logger.error("Preview fetch error:", error);
res.status(500).json({
error: "Failed to fetch preview",
message: error.message,
@@ -50,7 +51,7 @@ router.get("/discover/:nameOrMbid", async (req, res) => {
try {
const cached = await redisClient.get(cacheKey);
if (cached) {
console.log(`[Discovery] Cache hit for artist: ${nameOrMbid}`);
logger.debug(`[Discovery] Cache hit for artist: ${nameOrMbid}`);
return res.json(JSON.parse(cached));
}
} catch (err) {
@@ -108,7 +109,7 @@ router.get("/discover/:nameOrMbid", async (req, res) => {
lowerBio.includes("multiple artists")
) {
// This is a disambiguation page - don't show it
console.log(
logger.debug(
` Filtered out disambiguation biography for ${artistName}`
);
bio = null;
@@ -125,7 +126,7 @@ router.get("/discover/:nameOrMbid", async (req, res) => {
10
);
} catch (error) {
console.log(`Failed to get top tracks for ${artistName}`);
logger.debug(`Failed to get top tracks for ${artistName}`);
}
}
@@ -136,9 +137,9 @@ router.get("/discover/:nameOrMbid", async (req, res) => {
if (mbid) {
try {
image = await fanartService.getArtistImage(mbid);
console.log(`Fanart.tv image for ${artistName}`);
logger.debug(`Fanart.tv image for ${artistName}`);
} catch (error) {
console.log(
logger.debug(
`✗ Failed to get Fanart.tv image for ${artistName}`
);
}
@@ -149,10 +150,10 @@ router.get("/discover/:nameOrMbid", async (req, res) => {
try {
image = await deezerService.getArtistImage(artistName);
if (image) {
console.log(`Deezer image for ${artistName}`);
logger.debug(`Deezer image for ${artistName}`);
}
} catch (error) {
console.log(` Failed to get Deezer image for ${artistName}`);
logger.debug(` Failed to get Deezer image for ${artistName}`);
}
}
@@ -165,9 +166,9 @@ router.get("/discover/:nameOrMbid", async (req, res) => {
!lastFmImage.includes("2a96cbd8b46e442fc41c2b86b821562f")
) {
image = lastFmImage;
console.log(`Last.fm image for ${artistName}`);
logger.debug(`Last.fm image for ${artistName}`);
} else {
console.log(` Last.fm returned placeholder for ${artistName}`);
logger.debug(` Last.fm returned placeholder for ${artistName}`);
}
}
@@ -265,7 +266,7 @@ router.get("/discover/:nameOrMbid", async (req, res) => {
return 0;
});
} catch (error) {
console.error(
logger.error(
`Failed to get discography for ${artistName}:`,
error
);
@@ -355,14 +356,14 @@ router.get("/discover/:nameOrMbid", async (req, res) => {
DISCOVERY_CACHE_TTL,
JSON.stringify(response)
);
console.log(`[Discovery] Cached artist: ${artistName}`);
logger.debug(`[Discovery] Cached artist: ${artistName}`);
} catch (err) {
// Redis errors are non-critical
}
res.json(response);
} catch (error: any) {
console.error("Artist discovery error:", error);
logger.error("Artist discovery error:", error);
res.status(500).json({
error: "Failed to fetch artist details",
message: error.message,
@@ -380,7 +381,7 @@ router.get("/album/:mbid", async (req, res) => {
try {
const cached = await redisClient.get(cacheKey);
if (cached) {
console.log(`[Discovery] Cache hit for album: ${mbid}`);
logger.debug(`[Discovery] Cache hit for album: ${mbid}`);
return res.json(JSON.parse(cached));
}
} catch (err) {
@@ -397,7 +398,7 @@ router.get("/album/:mbid", async (req, res) => {
} catch (error: any) {
// If 404, try as a release instead
if (error.response?.status === 404) {
console.log(
logger.debug(
`${mbid} is not a release-group, trying as release...`
);
release = await musicBrainzService.getRelease(mbid);
@@ -410,7 +411,7 @@ router.get("/album/:mbid", async (req, res) => {
releaseGroupId
);
} catch (err) {
console.error(
logger.error(
`Failed to get release-group ${releaseGroupId}`
);
}
@@ -439,7 +440,7 @@ router.get("/album/:mbid", async (req, res) => {
albumTitle
);
} catch (error) {
console.log(`Failed to get Last.fm info for ${albumTitle}`);
logger.debug(`Failed to get Last.fm info for ${albumTitle}`);
}
// Get tracks - if we have release, use it directly; otherwise get first release from group
@@ -454,7 +455,7 @@ router.get("/album/:mbid", async (req, res) => {
);
tracks = releaseDetails.media?.[0]?.tracks || [];
} catch (error) {
console.error(
logger.error(
`Failed to get tracks for release ${firstRelease.id}`
);
}
@@ -472,14 +473,14 @@ router.get("/album/:mbid", async (req, res) => {
const response = await fetch(coverArtUrl, { method: "HEAD" });
if (response.ok) {
coverUrl = coverArtUrl;
console.log(`Cover Art Archive has cover for ${albumTitle}`);
logger.debug(`Cover Art Archive has cover for ${albumTitle}`);
} else {
console.log(
logger.debug(
`✗ Cover Art Archive 404 for ${albumTitle}, trying Deezer...`
);
}
} catch (error) {
console.log(
logger.debug(
`✗ Cover Art Archive check failed for ${albumTitle}, trying Deezer...`
);
}
@@ -493,13 +494,13 @@ router.get("/album/:mbid", async (req, res) => {
);
if (deezerCover) {
coverUrl = deezerCover;
console.log(`Deezer has cover for ${albumTitle}`);
logger.debug(`Deezer has cover for ${albumTitle}`);
} else {
// Final fallback to Cover Art Archive URL (might 404, but better than nothing)
coverUrl = coverArtUrl;
}
} catch (error) {
console.log(` Deezer lookup failed for ${albumTitle}`);
logger.debug(` Deezer lookup failed for ${albumTitle}`);
// Final fallback to Cover Art Archive URL
coverUrl = coverArtUrl;
}
@@ -548,14 +549,14 @@ router.get("/album/:mbid", async (req, res) => {
DISCOVERY_CACHE_TTL,
JSON.stringify(response)
);
console.log(`[Discovery] Cached album: ${albumTitle}`);
logger.debug(`[Discovery] Cached album: ${albumTitle}`);
} catch (err) {
// Redis errors are non-critical
}
res.json(response);
} catch (error: any) {
console.error("Album discovery error:", error);
logger.error("Album discovery error:", error);
res.status(500).json({
error: "Failed to fetch album details",
message: error.message,
+91 -57
View File
@@ -1,4 +1,5 @@
import { Router } from "express";
import { logger } from "../utils/logger";
import { audiobookshelfService } from "../services/audiobookshelf";
import { audiobookCacheService } from "../services/audiobookCache";
import { prisma } from "../utils/db";
@@ -57,7 +58,7 @@ router.get(
res.json(transformed);
} catch (error: any) {
console.error("Error fetching continue listening:", error);
logger.error("Error fetching continue listening:", error);
res.status(500).json({
error: "Failed to fetch continue listening",
message: error.message,
@@ -83,14 +84,14 @@ router.post("/sync", requireAuthOrToken, apiLimiter, async (req, res) => {
.json({ error: "Audiobookshelf not enabled" });
}
console.log("[Audiobooks] Starting manual audiobook sync...");
logger.debug("[Audiobooks] Starting manual audiobook sync...");
const result = await audiobookCacheService.syncAll();
// Check how many have series after sync
const seriesCount = await prisma.audiobook.count({
where: { series: { not: null } },
});
console.log(
logger.debug(
`[Audiobooks] Sync complete. Books with series: ${seriesCount}`
);
@@ -108,7 +109,7 @@ router.post("/sync", requireAuthOrToken, apiLimiter, async (req, res) => {
result,
});
} catch (error: any) {
console.error("Audiobook sync failed:", error);
logger.error("Audiobook sync failed:", error);
res.status(500).json({
error: "Sync failed",
message: error.message,
@@ -122,7 +123,7 @@ router.post("/sync", requireAuthOrToken, apiLimiter, async (req, res) => {
*/
// Debug endpoint for series data
router.get("/debug-series", requireAuthOrToken, async (req, res) => {
console.log("[Audiobooks] Debug series endpoint called");
logger.debug("[Audiobooks] Debug series endpoint called");
try {
const { getSystemSettings } = await import("../utils/systemSettings");
const settings = await getSystemSettings();
@@ -135,7 +136,7 @@ router.get("/debug-series", requireAuthOrToken, async (req, res) => {
// Get raw data from Audiobookshelf
const rawBooks = await audiobookshelfService.getAllAudiobooks();
console.log(
logger.debug(
`[Audiobooks] Got ${rawBooks.length} books from Audiobookshelf`
);
@@ -145,7 +146,7 @@ router.get("/debug-series", requireAuthOrToken, async (req, res) => {
return metadata.series || metadata.seriesName;
});
console.log(
logger.debug(
`[Audiobooks] Books with series data: ${booksWithSeries.length}`
);
@@ -179,7 +180,7 @@ router.get("/debug-series", requireAuthOrToken, async (req, res) => {
fullSampleWithSeries: fullSample,
});
} catch (error: any) {
console.error("[Audiobooks] Debug series error:", error);
logger.error("[Audiobooks] Debug series error:", error);
res.status(500).json({ error: error.message });
}
});
@@ -207,7 +208,7 @@ router.get("/search", requireAuthOrToken, apiLimiter, async (req, res) => {
const results = await audiobookshelfService.searchAudiobooks(q);
res.json(results);
} catch (error: any) {
console.error("Error searching audiobooks:", error);
logger.error("Error searching audiobooks:", error);
res.status(500).json({
error: "Failed to search audiobooks",
message: error.message,
@@ -220,7 +221,7 @@ router.get("/search", requireAuthOrToken, apiLimiter, async (req, res) => {
* Get all audiobooks from cached database (instant, no API calls)
*/
router.get("/", requireAuthOrToken, apiLimiter, async (req, res) => {
console.log("[Audiobooks] GET / - fetching audiobooks list");
logger.debug("[Audiobooks] GET / - fetching audiobooks list");
try {
// Check if Audiobookshelf is enabled first
const { getSystemSettings } = await import("../utils/systemSettings");
@@ -296,7 +297,7 @@ router.get("/", requireAuthOrToken, apiLimiter, async (req, res) => {
res.json(audiobooksWithProgress);
} catch (error: any) {
console.error("Error fetching audiobooks:", error);
logger.error("Error fetching audiobooks:", error);
res.status(500).json({
error: "Failed to fetch audiobooks",
message: error.message,
@@ -394,7 +395,7 @@ router.get(
res.json(seriesBooks);
} catch (error: any) {
console.error("Error fetching series:", error);
logger.error("Error fetching series:", error);
res.status(500).json({
error: "Failed to fetch series",
message: error.message,
@@ -419,7 +420,7 @@ router.options("/:id/cover", (req, res) => {
/**
* GET /audiobooks/:id/cover
* Serve cached cover image from local disk (instant, no proxying)
* Serve cached cover image from local disk, or proxy from Audiobookshelf if not cached
* NO RATE LIMITING - These are static files served from disk with aggressive caching
*/
router.get("/:id/cover", async (req, res) => {
@@ -431,7 +432,7 @@ router.get("/:id/cover", async (req, res) => {
const audiobook = await prisma.audiobook.findUnique({
where: { id },
select: { localCoverPath: true },
select: { localCoverPath: true, coverUrl: true },
});
let coverPath = audiobook?.localCoverPath;
@@ -456,25 +457,54 @@ router.get("/:id/cover", async (req, res) => {
}
}
if (!coverPath) {
return res.status(404).json({ error: "Cover not found" });
// If local cover exists, serve it
if (coverPath && fs.existsSync(coverPath)) {
const origin = req.headers.origin || "http://localhost:3030";
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
return res.sendFile(coverPath);
}
// Verify file exists before sending
if (!fs.existsSync(coverPath)) {
return res.status(404).json({ error: "Cover file missing" });
// Fallback: proxy from Audiobookshelf if coverUrl is available
if (audiobook?.coverUrl) {
const { getSystemSettings } = await import("../utils/systemSettings");
const settings = await getSystemSettings();
if (settings?.audiobookshelfUrl && settings?.audiobookshelfApiKey) {
const baseUrl = settings.audiobookshelfUrl.replace(/\/$/, "");
const coverApiUrl = `${baseUrl}/api/${audiobook.coverUrl}`;
try {
const response = await fetch(coverApiUrl, {
headers: {
Authorization: `Bearer ${settings.audiobookshelfApiKey}`,
},
});
if (response.ok) {
const origin = req.headers.origin || "http://localhost:3030";
res.setHeader("Content-Type", response.headers.get("content-type") || "image/jpeg");
res.setHeader("Cache-Control", "public, max-age=86400"); // 24 hours for proxied
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
// Stream the response body to client
const buffer = await response.arrayBuffer();
return res.send(Buffer.from(buffer));
}
} catch (proxyError: any) {
logger.error(`[Audiobook Cover] Proxy error for ${id}:`, proxyError.message);
}
}
}
// Serve image from local disk with aggressive caching and CORS headers
// Use specific origin instead of * to support credentials mode
const origin = req.headers.origin || "http://localhost:3030";
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
res.sendFile(coverPath);
// No cover available
return res.status(404).json({ error: "Cover not found" });
} catch (error: any) {
console.error("Error serving cover:", error);
logger.error("Error serving cover:", error);
res.status(500).json({
error: "Failed to serve cover",
message: error.message,
@@ -509,18 +539,22 @@ router.get("/:id", requireAuthOrToken, apiLimiter, async (req, res) => {
audiobook.lastSyncedAt <
new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
) {
console.log(
logger.debug(
`[AUDIOBOOK] Audiobook ${id} not cached or stale, fetching...`
);
audiobook = await audiobookCacheService.getAudiobook(id);
}
if (!audiobook) {
return res.status(404).json({ error: "Audiobook not found" });
}
// Get chapters and audio files from API (these change less frequently)
let absBook;
try {
absBook = await audiobookshelfService.getAudiobook(id);
} catch (apiError: any) {
console.warn(
logger.warn(
` Failed to fetch live data from Audiobookshelf for ${id}, using cached data only:`,
apiError.message
);
@@ -567,7 +601,7 @@ router.get("/:id", requireAuthOrToken, apiLimiter, async (req, res) => {
res.json(response);
} catch (error: any) {
console.error("Error fetching audiobook__", error);
logger.error("Error fetching audiobook__", error);
res.status(500).json({
error: "Failed to fetch audiobook",
message: error.message,
@@ -581,17 +615,17 @@ router.get("/:id", requireAuthOrToken, apiLimiter, async (req, res) => {
*/
router.get("/:id/stream", requireAuthOrToken, async (req, res) => {
try {
console.log(
logger.debug(
`[Audiobook Stream] Request for audiobook: ${req.params.id}`
);
console.log(`[Audiobook Stream] User: ${req.user?.id || "unknown"}`);
logger.debug(`[Audiobook Stream] User: ${req.user?.id || "unknown"}`);
// Check if Audiobookshelf is enabled
const { getSystemSettings } = await import("../utils/systemSettings");
const settings = await getSystemSettings();
if (!settings?.audiobookshelfEnabled) {
console.log("[Audiobook Stream] Audiobookshelf not enabled");
logger.debug("[Audiobook Stream] Audiobookshelf not enabled");
return res
.status(503)
.json({ error: "Audiobookshelf is not configured" });
@@ -600,7 +634,7 @@ router.get("/:id/stream", requireAuthOrToken, async (req, res) => {
const { id } = req.params;
const rangeHeader = req.headers.range as string | undefined;
console.log(
logger.debug(
`[Audiobook Stream] Fetching stream for ${id}, range: ${
rangeHeader || "none"
}`
@@ -609,7 +643,7 @@ router.get("/:id/stream", requireAuthOrToken, async (req, res) => {
const { stream, headers, status } =
await audiobookshelfService.streamAudiobook(id, rangeHeader);
console.log(
logger.debug(
`[Audiobook Stream] Got stream, status: ${status}, content-type: ${headers["content-type"]}`
);
@@ -645,7 +679,7 @@ router.get("/:id/stream", requireAuthOrToken, async (req, res) => {
stream.pipe(res);
stream.on("error", (error: any) => {
console.error("[Audiobook Stream] Stream error:", error);
logger.error("[Audiobook Stream] Stream error:", error);
if (!res.headersSent) {
res.status(500).json({
error: "Failed to stream audiobook",
@@ -656,7 +690,7 @@ router.get("/:id/stream", requireAuthOrToken, async (req, res) => {
}
});
} catch (error: any) {
console.error("[Audiobook Stream] Error:", error.message);
logger.error("[Audiobook Stream] Error:", error.message);
res.status(500).json({
error: "Failed to stream audiobook",
message: error.message,
@@ -704,30 +738,30 @@ router.post(
? Math.max(rawDuration, 0)
: 0;
console.log(`\n [AUDIOBOOK PROGRESS] Received update:`);
console.log(` User: ${req.user!.username}`);
console.log(` Audiobook ID: ${id}`);
console.log(
logger.debug(`\n [AUDIOBOOK PROGRESS] Received update:`);
logger.debug(` User: ${req.user!.username}`);
logger.debug(` Audiobook ID: ${id}`);
logger.debug(
` Current Time: ${currentTime}s (${Math.floor(
currentTime / 60
)} mins)`
);
console.log(
logger.debug(
` Duration: ${durationValue}s (${Math.floor(
durationValue / 60
)} mins)`
);
if (durationValue > 0) {
console.log(
logger.debug(
` Progress: ${(
(currentTime / durationValue) *
100
).toFixed(1)}%`
);
} else {
console.log(" Progress: duration unknown");
logger.debug(" Progress: duration unknown");
}
console.log(` Finished: ${!!isFinished}`);
logger.debug(` Finished: ${!!isFinished}`);
// Pull cached metadata to avoid hitting Audiobookshelf for every update
const [cachedAudiobook, existingProgress] = await Promise.all([
@@ -799,7 +833,7 @@ router.post(
},
});
console.log(` Progress saved to database`);
logger.debug(` Progress saved to database`);
// Also update progress in Audiobookshelf
try {
@@ -809,9 +843,9 @@ router.post(
fallbackDuration,
isFinished
);
console.log(` Progress synced to Audiobookshelf`);
logger.debug(` Progress synced to Audiobookshelf`);
} catch (error) {
console.error(
logger.error(
"Failed to sync progress to Audiobookshelf:",
error
);
@@ -830,7 +864,7 @@ router.post(
},
});
} catch (error: any) {
console.error("Error updating progress:", error);
logger.error("Error updating progress:", error);
res.status(500).json({
error: "Failed to update progress",
message: error.message,
@@ -864,9 +898,9 @@ router.delete(
const { id } = req.params;
console.log(`\n[AUDIOBOOK PROGRESS] Removing progress:`);
console.log(` User: ${req.user!.username}`);
console.log(` Audiobook ID: ${id}`);
logger.debug(`\n[AUDIOBOOK PROGRESS] Removing progress:`);
logger.debug(` User: ${req.user!.username}`);
logger.debug(` Audiobook ID: ${id}`);
// Delete progress from our database
await prisma.audiobookProgress.deleteMany({
@@ -876,14 +910,14 @@ router.delete(
},
});
console.log(` Progress removed from database`);
logger.debug(` Progress removed from database`);
// Also remove progress from Audiobookshelf
try {
await audiobookshelfService.updateProgress(id, 0, 0, false);
console.log(` Progress reset in Audiobookshelf`);
logger.debug(` Progress reset in Audiobookshelf`);
} catch (error) {
console.error(
logger.error(
"Failed to reset progress in Audiobookshelf:",
error
);
@@ -895,7 +929,7 @@ router.delete(
message: "Progress removed",
});
} catch (error: any) {
console.error("Error removing progress:", error);
logger.error("Error removing progress:", error);
res.status(500).json({
error: "Failed to remove progress",
message: error.message,
+65 -13
View File
@@ -1,11 +1,13 @@
import { Router } from "express";
import { logger } from "../utils/logger";
import bcrypt from "bcrypt";
import { prisma } from "../utils/db";
import { z } from "zod";
import speakeasy from "speakeasy";
import QRCode from "qrcode";
import crypto from "crypto";
import { requireAuth, requireAdmin, generateToken } from "../middleware/auth";
import jwt from "jsonwebtoken";
import { requireAuth, requireAdmin, generateToken, generateRefreshToken } from "../middleware/auth";
import { encrypt, decrypt } from "../utils/encryption";
const router = Router();
@@ -119,15 +121,21 @@ router.post("/login", async (req, res) => {
}
}
// Generate JWT token
// Generate JWT tokens
const jwtToken = generateToken({
id: user.id,
username: user.username,
role: user.role,
tokenVersion: user.tokenVersion,
});
const refreshToken = generateRefreshToken({
id: user.id,
tokenVersion: user.tokenVersion,
});
res.json({
token: jwtToken,
refreshToken: refreshToken,
user: {
id: user.id,
username: user.username,
@@ -138,7 +146,7 @@ router.post("/login", async (req, res) => {
if (err instanceof z.ZodError) {
return res.status(400).json({ error: "Invalid request", details: err.errors });
}
console.error("Login error:", err);
logger.error("Login error:", err);
res.status(500).json({ error: "Internal error" });
}
});
@@ -150,6 +158,47 @@ router.post("/logout", (req, res) => {
res.json({ message: "Logged out" });
});
// POST /auth/refresh - Refresh access token using refresh token
router.post("/refresh", async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(400).json({ error: "Refresh token required" });
}
try {
const decoded = jwt.verify(refreshToken, process.env.JWT_SECRET || process.env.SESSION_SECRET!) as any;
if (decoded.type !== "refresh") {
return res.status(401).json({ error: "Invalid refresh token" });
}
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: { id: true, username: true, role: true, tokenVersion: true }
});
if (!user) {
return res.status(401).json({ error: "User not found" });
}
// Validate tokenVersion
if (decoded.tokenVersion !== user.tokenVersion) {
return res.status(401).json({ error: "Token invalidated" });
}
const newAccessToken = generateToken(user);
const newRefreshToken = generateRefreshToken(user);
return res.json({
token: newAccessToken,
refreshToken: newRefreshToken
});
} catch (error) {
return res.status(401).json({ error: "Invalid refresh token" });
}
});
/**
* @openapi
* /auth/me:
@@ -226,16 +275,19 @@ router.post("/change-password", requireAuth, async (req, res) => {
.json({ error: "Current password is incorrect" });
}
// Update password
// Update password and increment tokenVersion to invalidate all existing tokens
const newPasswordHash = await bcrypt.hash(newPassword, 10);
await prisma.user.update({
where: { id: req.user!.id },
data: { passwordHash: newPasswordHash },
data: {
passwordHash: newPasswordHash,
tokenVersion: { increment: 1 }
},
});
res.json({ message: "Password changed successfully" });
} catch (error) {
console.error("Change password error:", error);
logger.error("Change password error:", error);
res.status(500).json({ error: "Failed to change password" });
}
});
@@ -256,7 +308,7 @@ router.get("/users", requireAuth, requireAdmin, async (req, res) => {
res.json(users);
} catch (error) {
console.error("Get users error:", error);
logger.error("Get users error:", error);
res.status(500).json({ error: "Failed to get users" });
}
});
@@ -320,7 +372,7 @@ router.post("/create-user", requireAuth, requireAdmin, async (req, res) => {
createdAt: user.createdAt,
});
} catch (error) {
console.error("Create user error:", error);
logger.error("Create user error:", error);
res.status(500).json({ error: "Failed to create user" });
}
});
@@ -344,7 +396,7 @@ router.delete("/users/:id", requireAuth, requireAdmin, async (req, res) => {
res.json({ message: "User deleted successfully" });
} catch (error: any) {
console.error("Delete user error:", error);
logger.error("Delete user error:", error);
if (error.code === "P2025") {
return res.status(404).json({ error: "User not found" });
}
@@ -382,7 +434,7 @@ router.post("/2fa/setup", requireAuth, async (req, res) => {
qrCode: qrCodeDataUrl,
});
} catch (error) {
console.error("2FA setup error:", error);
logger.error("2FA setup error:", error);
res.status(500).json({ error: "Failed to setup 2FA" });
}
});
@@ -448,7 +500,7 @@ router.post("/2fa/enable", requireAuth, async (req, res) => {
recoveryCodes: recoveryCodes,
});
} catch (error) {
console.error("2FA enable error:", error);
logger.error("2FA enable error:", error);
res.status(500).json({ error: "Failed to enable 2FA" });
}
});
@@ -505,7 +557,7 @@ router.post("/2fa/disable", requireAuth, async (req, res) => {
res.json({ message: "2FA disabled successfully" });
} catch (error) {
console.error("2FA disable error:", error);
logger.error("2FA disable error:", error);
res.status(500).json({ error: "Failed to disable 2FA" });
}
});
@@ -524,7 +576,7 @@ router.get("/2fa/status", requireAuth, async (req, res) => {
res.json({ enabled: user.twoFactorEnabled });
} catch (error) {
console.error("2FA status error:", error);
logger.error("2FA status error:", error);
res.status(500).json({ error: "Failed to get 2FA status" });
}
});
+22 -21
View File
@@ -1,4 +1,5 @@
import { Router } from "express";
import { logger } from "../utils/logger";
import { requireAuthOrToken } from "../middleware/auth";
import { spotifyService } from "../services/spotify";
import { deezerService, DeezerPlaylistPreview, DeezerRadioStation } from "../services/deezer";
@@ -68,10 +69,10 @@ function deezerRadioToUnified(radio: DeezerRadioStation): PlaylistPreview {
router.get("/playlists/featured", async (req, res) => {
try {
const limit = Math.min(parseInt(req.query.limit as string) || 50, 200);
console.log(`[Browse] Fetching featured playlists (limit: ${limit})...`);
logger.debug(`[Browse] Fetching featured playlists (limit: ${limit})...`);
const playlists = await deezerService.getFeaturedPlaylists(limit);
console.log(`[Browse] Got ${playlists.length} Deezer playlists`);
logger.debug(`[Browse] Got ${playlists.length} Deezer playlists`);
res.json({
playlists: playlists.map(deezerPlaylistToUnified),
@@ -79,7 +80,7 @@ router.get("/playlists/featured", async (req, res) => {
source: "deezer",
});
} catch (error: any) {
console.error("Browse featured playlists error:", error);
logger.error("Browse featured playlists error:", error);
res.status(500).json({ error: error.message || "Failed to fetch playlists" });
}
});
@@ -96,10 +97,10 @@ router.get("/playlists/search", async (req, res) => {
}
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
console.log(`[Browse] Searching playlists for "${query}"...`);
logger.debug(`[Browse] Searching playlists for "${query}"...`);
const playlists = await deezerService.searchPlaylists(query, limit);
console.log(`[Browse] Search "${query}": ${playlists.length} results`);
logger.debug(`[Browse] Search "${query}": ${playlists.length} results`);
res.json({
playlists: playlists.map(deezerPlaylistToUnified),
@@ -108,7 +109,7 @@ router.get("/playlists/search", async (req, res) => {
source: "deezer",
});
} catch (error: any) {
console.error("Browse search playlists error:", error);
logger.error("Browse search playlists error:", error);
res.status(500).json({ error: error.message || "Failed to search playlists" });
}
});
@@ -132,7 +133,7 @@ router.get("/playlists/:id", async (req, res) => {
url: `https://www.deezer.com/playlist/${id}`,
});
} catch (error: any) {
console.error("Playlist fetch error:", error);
logger.error("Playlist fetch error:", error);
res.status(500).json({ error: error.message || "Failed to fetch playlist" });
}
});
@@ -147,7 +148,7 @@ router.get("/playlists/:id", async (req, res) => {
*/
router.get("/radios", async (req, res) => {
try {
console.log("[Browse] Fetching radio stations...");
logger.debug("[Browse] Fetching radio stations...");
const radios = await deezerService.getRadioStations();
res.json({
@@ -156,7 +157,7 @@ router.get("/radios", async (req, res) => {
source: "deezer",
});
} catch (error: any) {
console.error("Browse radios error:", error);
logger.error("Browse radios error:", error);
res.status(500).json({ error: error.message || "Failed to fetch radios" });
}
});
@@ -167,7 +168,7 @@ router.get("/radios", async (req, res) => {
*/
router.get("/radios/by-genre", async (req, res) => {
try {
console.log("[Browse] Fetching radios by genre...");
logger.debug("[Browse] Fetching radios by genre...");
const genresWithRadios = await deezerService.getRadiosByGenre();
// Transform to include unified format
@@ -183,7 +184,7 @@ router.get("/radios/by-genre", async (req, res) => {
source: "deezer",
});
} catch (error: any) {
console.error("Browse radios by genre error:", error);
logger.error("Browse radios by genre error:", error);
res.status(500).json({ error: error.message || "Failed to fetch radios" });
}
});
@@ -195,7 +196,7 @@ router.get("/radios/by-genre", async (req, res) => {
router.get("/radios/:id", async (req, res) => {
try {
const { id } = req.params;
console.log(`[Browse] Fetching radio ${id} tracks...`);
logger.debug(`[Browse] Fetching radio ${id} tracks...`);
const radioPlaylist = await deezerService.getRadioTracks(id);
@@ -209,7 +210,7 @@ router.get("/radios/:id", async (req, res) => {
type: "radio",
});
} catch (error: any) {
console.error("Radio tracks error:", error);
logger.error("Radio tracks error:", error);
res.status(500).json({ error: error.message || "Failed to fetch radio tracks" });
}
});
@@ -224,7 +225,7 @@ router.get("/radios/:id", async (req, res) => {
*/
router.get("/genres", async (req, res) => {
try {
console.log("[Browse] Fetching genres...");
logger.debug("[Browse] Fetching genres...");
const genres = await deezerService.getGenres();
res.json({
@@ -233,7 +234,7 @@ router.get("/genres", async (req, res) => {
source: "deezer",
});
} catch (error: any) {
console.error("Browse genres error:", error);
logger.error("Browse genres error:", error);
res.status(500).json({ error: error.message || "Failed to fetch genres" });
}
});
@@ -249,7 +250,7 @@ router.get("/genres/:id", async (req, res) => {
return res.status(400).json({ error: "Invalid genre ID" });
}
console.log(`[Browse] Fetching content for genre ${genreId}...`);
logger.debug(`[Browse] Fetching content for genre ${genreId}...`);
const content = await deezerService.getEditorialContent(genreId);
res.json({
@@ -259,7 +260,7 @@ router.get("/genres/:id", async (req, res) => {
source: "deezer",
});
} catch (error: any) {
console.error("Genre content error:", error);
logger.error("Genre content error:", error);
res.status(500).json({ error: error.message || "Failed to fetch genre content" });
}
});
@@ -290,7 +291,7 @@ router.get("/genres/:id/playlists", async (req, res) => {
source: "deezer",
});
} catch (error: any) {
console.error("Genre playlists error:", error);
logger.error("Genre playlists error:", error);
res.status(500).json({ error: error.message || "Failed to fetch genre playlists" });
}
});
@@ -337,7 +338,7 @@ router.post("/playlists/parse", async (req, res) => {
error: "Invalid or unsupported URL. Please provide a Spotify or Deezer playlist URL."
});
} catch (error: any) {
console.error("Parse URL error:", error);
logger.error("Parse URL error:", error);
res.status(500).json({ error: error.message || "Failed to parse URL" });
}
});
@@ -353,7 +354,7 @@ router.post("/playlists/parse", async (req, res) => {
*/
router.get("/all", async (req, res) => {
try {
console.log("[Browse] Fetching browse content (playlists + genres)...");
logger.debug("[Browse] Fetching browse content (playlists + genres)...");
// Only fetch playlists and genres - radios are now internal library-based
const [playlists, genres] = await Promise.all([
@@ -369,7 +370,7 @@ router.get("/all", async (req, res) => {
source: "deezer",
});
} catch (error: any) {
console.error("Browse all error:", error);
logger.error("Browse all error:", error);
res.status(500).json({ error: error.message || "Failed to fetch browse content" });
}
});
+6 -5
View File
@@ -1,4 +1,5 @@
import { Router } from "express";
import { logger } from "../utils/logger";
import { requireAuthOrToken } from "../middleware/auth";
import { prisma } from "../utils/db";
import crypto from "crypto";
@@ -64,7 +65,7 @@ router.post("/generate", requireAuthOrToken, async (req, res) => {
expiresIn: 300, // 5 minutes in seconds
});
} catch (error) {
console.error("Generate device link code error:", error);
logger.error("Generate device link code error:", error);
res.status(500).json({ error: "Failed to generate device link code" });
}
});
@@ -123,7 +124,7 @@ router.post("/verify", async (req, res) => {
username: linkCode.user.username,
});
} catch (error) {
console.error("Verify device link code error:", error);
logger.error("Verify device link code error:", error);
res.status(500).json({ error: "Failed to verify device link code" });
}
});
@@ -161,7 +162,7 @@ router.get("/status/:code", async (req, res) => {
expiresAt: linkCode.expiresAt,
});
} catch (error) {
console.error("Check device link status error:", error);
logger.error("Check device link status error:", error);
res.status(500).json({ error: "Failed to check status" });
}
});
@@ -184,7 +185,7 @@ router.get("/devices", requireAuthOrToken, async (req, res) => {
res.json(apiKeys);
} catch (error) {
console.error("Get devices error:", error);
logger.error("Get devices error:", error);
res.status(500).json({ error: "Failed to get devices" });
}
});
@@ -209,7 +210,7 @@ router.delete("/devices/:id", requireAuthOrToken, async (req, res) => {
res.json({ success: true });
} catch (error) {
console.error("Revoke device error:", error);
logger.error("Revoke device error:", error);
res.status(500).json({ error: "Failed to revoke device" });
}
});
File diff suppressed because it is too large Load Diff
+232 -65
View File
@@ -1,9 +1,11 @@
import { Router } from "express";
import { logger } from "../utils/logger";
import { requireAuthOrToken } from "../middleware/auth";
import { prisma } from "../utils/db";
import { config } from "../config";
import { lidarrService } from "../services/lidarr";
import { musicBrainzService } from "../services/musicbrainz";
import { lastFmService } from "../services/lastfm";
import { simpleDownloadManager } from "../services/simpleDownloadManager";
import crypto from "crypto";
@@ -11,6 +13,78 @@ const router = Router();
router.use(requireAuthOrToken);
/**
* Verify and potentially correct artist name before download
* Uses multiple sources for canonical name resolution:
* 1. MusicBrainz (if MBID provided) - most authoritative
* 2. LastFM correction API - handles aliases and misspellings
* 3. Original name - fallback
*
* @returns Object with verified name and whether correction was applied
*/
async function verifyArtistName(
artistName: string,
artistMbid?: string
): Promise<{
verifiedName: string;
wasCorrected: boolean;
source: "musicbrainz" | "lastfm" | "original";
originalName: string;
}> {
const originalName = artistName;
// Strategy 1: If we have MBID, use MusicBrainz as authoritative source
if (artistMbid) {
try {
const mbArtist = await musicBrainzService.getArtist(artistMbid);
if (mbArtist?.name) {
return {
verifiedName: mbArtist.name,
wasCorrected:
mbArtist.name.toLowerCase() !==
artistName.toLowerCase(),
source: "musicbrainz",
originalName,
};
}
} catch (error) {
logger.warn(
`MusicBrainz lookup failed for MBID ${artistMbid}:`,
error
);
}
}
// Strategy 2: Use LastFM correction API
try {
const correction = await lastFmService.getArtistCorrection(artistName);
if (correction?.corrected) {
logger.debug(
`[VERIFY] LastFM correction: "${artistName}" → "${correction.canonicalName}"`
);
return {
verifiedName: correction.canonicalName,
wasCorrected: true,
source: "lastfm",
originalName,
};
}
} catch (error) {
logger.warn(
`LastFM correction lookup failed for "${artistName}":`,
error
);
}
// Strategy 3: Return original name
return {
verifiedName: artistName,
wasCorrected: false,
source: "original",
originalName,
};
}
// POST /downloads - Create download job
router.post("/", async (req, res) => {
try {
@@ -75,6 +149,18 @@ router.post("/", async (req, res) => {
});
}
// Single album download - verify artist name before proceeding
let verifiedArtistName = artistName;
if (type === "album" && artistName) {
const verification = await verifyArtistName(artistName, mbid);
if (verification.wasCorrected) {
logger.debug(
`[DOWNLOAD] Artist name verified: "${artistName}" → "${verification.verifiedName}" (source: ${verification.source})`
);
verifiedArtistName = verification.verifiedName;
}
}
// Single album download - check for existing job first
const existingJob = await prisma.downloadJob.findFirst({
where: {
@@ -84,7 +170,9 @@ router.post("/", async (req, res) => {
});
if (existingJob) {
console.log(`[DOWNLOAD] Job already exists for ${mbid}: ${existingJob.id} (${existingJob.status})`);
logger.debug(
`[DOWNLOAD] Job already exists for ${mbid}: ${existingJob.id} (${existingJob.status})`
);
return res.json({
id: existingJob.id,
status: existingJob.status,
@@ -105,13 +193,13 @@ router.post("/", async (req, res) => {
metadata: {
downloadType,
rootFolderPath,
artistName,
artistName: verifiedArtistName,
albumTitle,
},
},
});
console.log(
logger.debug(
`[DOWNLOAD] Triggering Lidarr: ${type} "${subject}" -> ${rootFolderPath}`
);
@@ -122,10 +210,10 @@ router.post("/", async (req, res) => {
mbid,
subject,
rootFolderPath,
artistName,
verifiedArtistName,
albumTitle
).catch((error) => {
console.error(
logger.error(
`Download processing failed for job ${job.id}:`,
error
);
@@ -139,7 +227,7 @@ router.post("/", async (req, res) => {
message: "Download job created. Processing in background.",
});
} catch (error) {
console.error("Create download job error:", error);
logger.error("Create download job error:", error);
res.status(500).json({ error: "Failed to create download job" });
}
});
@@ -154,27 +242,66 @@ async function processArtistDownload(
rootFolderPath: string,
downloadType: string
): Promise<{ id: string; subject: string }[]> {
console.log(`\n Processing artist download: ${artistName}`);
console.log(` Artist MBID: ${artistMbid}`);
logger.debug(`\n Processing artist download: ${artistName}`);
logger.debug(` Artist MBID: ${artistMbid}`);
// Generate a batch ID to group all album downloads
const batchId = crypto.randomUUID();
console.log(` Batch ID: ${batchId}`);
logger.debug(` Batch ID: ${batchId}`);
// CRITICAL FIX: Resolve canonical artist name from MusicBrainz
// Last.fm may return aliases (e.g., "blink" for "blink-182")
// Lidarr needs the official name to find the correct artist
let canonicalArtistName = artistName;
try {
logger.debug(` Resolving canonical artist name from MusicBrainz...`);
const mbArtist = await musicBrainzService.getArtist(artistMbid);
if (mbArtist && mbArtist.name) {
canonicalArtistName = mbArtist.name;
if (canonicalArtistName !== artistName) {
logger.debug(
` ✓ Canonical name resolved: "${artistName}" → "${canonicalArtistName}"`
);
} else {
logger.debug(
` ✓ Name matches canonical: "${canonicalArtistName}"`
);
}
}
} catch (mbError: any) {
logger.warn(` ⚠ MusicBrainz lookup failed: ${mbError.message}`);
// Fallback to LastFM correction
try {
const correction = await lastFmService.getArtistCorrection(
artistName
);
if (correction?.canonicalName) {
canonicalArtistName = correction.canonicalName;
logger.debug(
` ✓ Name resolved via LastFM: "${artistName}" → "${canonicalArtistName}"`
);
}
} catch (lfmError) {
logger.warn(
` ⚠ LastFM correction also failed, using original name`
);
}
}
try {
// First, add the artist to Lidarr (this monitors all albums)
const lidarrArtist = await lidarrService.addArtist(
artistMbid,
artistName,
canonicalArtistName,
rootFolderPath
);
if (!lidarrArtist) {
console.log(` Failed to add artist to Lidarr`);
logger.debug(` Failed to add artist to Lidarr`);
throw new Error("Failed to add artist to Lidarr");
}
console.log(` Artist added to Lidarr (ID: ${lidarrArtist.id})`);
logger.debug(` Artist added to Lidarr (ID: ${lidarrArtist.id})`);
// Fetch albums from MusicBrainz
const releaseGroups = await musicBrainzService.getReleaseGroups(
@@ -183,12 +310,12 @@ async function processArtistDownload(
100
);
console.log(
logger.debug(
` Found ${releaseGroups.length} albums/EPs from MusicBrainz`
);
if (releaseGroups.length === 0) {
console.log(` No albums found for artist`);
logger.debug(` No albums found for artist`);
return [];
}
@@ -206,49 +333,84 @@ async function processArtistDownload(
});
if (existingAlbum) {
console.log(` Skipping "${albumTitle}" - already in library`);
logger.debug(` Skipping "${albumTitle}" - already in library`);
continue;
}
// Check if there's already a pending/processing job for this album
const existingJob = await prisma.downloadJob.findFirst({
where: {
targetMbid: albumMbid,
status: { in: ["pending", "processing"] },
},
// Use transaction to prevent race conditions when creating jobs
const jobResult = await prisma.$transaction(async (tx) => {
// Check for existing active job
const existingJob = await tx.downloadJob.findFirst({
where: {
targetMbid: albumMbid,
status: { in: ["pending", "processing"] },
},
});
if (existingJob) {
return {
skipped: true,
job: existingJob,
reason: "already_queued",
};
}
// Also check for recently failed job (within last 30 seconds) to prevent spam retries
const recentFailed = await tx.downloadJob.findFirst({
where: {
targetMbid: albumMbid,
status: "failed",
completedAt: { gte: new Date(Date.now() - 30000) },
},
});
if (recentFailed) {
return {
skipped: true,
job: recentFailed,
reason: "recently_failed",
};
}
// Create new job inside transaction
const now = new Date();
const job = await tx.downloadJob.create({
data: {
userId,
subject: albumSubject,
type: "album",
targetMbid: albumMbid,
status: "pending",
metadata: {
downloadType,
rootFolderPath,
artistName,
artistMbid,
albumTitle,
batchId, // Link all albums in this artist download
batchArtist: artistName,
createdAt: now.toISOString(), // Track when job was created for timeout
},
},
});
return { skipped: false, job };
});
if (existingJob) {
console.log(
` Skipping "${albumTitle}" - already in download queue`
if (jobResult.skipped) {
logger.debug(
` Skipping "${albumTitle}" - ${
jobResult.reason === "recently_failed"
? "recently failed"
: "already in download queue"
}`
);
continue;
}
// Create download job for this album
const now = new Date();
const job = await prisma.downloadJob.create({
data: {
userId,
subject: albumSubject,
type: "album",
targetMbid: albumMbid,
status: "pending",
metadata: {
downloadType,
rootFolderPath,
artistName,
artistMbid,
albumTitle,
batchId, // Link all albums in this artist download
batchArtist: artistName,
createdAt: now.toISOString(), // Track when job was created for timeout
},
},
});
const job = jobResult.job;
jobs.push({ id: job.id, subject: albumSubject });
console.log(` [JOB] Created job for: ${albumSubject}`);
logger.debug(` [JOB] Created job for: ${albumSubject}`);
// Start the download in background
processDownload(
@@ -260,14 +422,14 @@ async function processArtistDownload(
artistName,
albumTitle
).catch((error) => {
console.error(`Download failed for ${albumSubject}:`, error);
logger.error(`Download failed for ${albumSubject}:`, error);
});
}
console.log(` Created ${jobs.length} album download jobs`);
logger.debug(` Created ${jobs.length} album download jobs`);
return jobs;
} catch (error: any) {
console.error(` Failed to process artist download:`, error.message);
logger.error(` Failed to process artist download:`, error.message);
throw error;
}
}
@@ -284,7 +446,7 @@ async function processDownload(
) {
const job = await prisma.downloadJob.findUnique({ where: { id: jobId } });
if (!job) {
console.error(`Job ${jobId} not found`);
logger.error(`Job ${jobId} not found`);
return;
}
@@ -304,7 +466,7 @@ async function processDownload(
}
}
console.log(`Parsed: Artist="${parsedArtist}", Album="${parsedAlbum}"`);
logger.debug(`Parsed: Artist="${parsedArtist}", Album="${parsedAlbum}"`);
// Use simple download manager for album downloads
const result = await simpleDownloadManager.startDownload(
@@ -316,7 +478,7 @@ async function processDownload(
);
if (!result.success) {
console.error(`Failed to start download: ${result.error}`);
logger.error(`Failed to start download: ${result.error}`);
}
}
}
@@ -335,12 +497,12 @@ router.delete("/clear-all", async (req, res) => {
const result = await prisma.downloadJob.deleteMany({ where });
console.log(
logger.debug(
` Cleared ${result.count} download jobs for user ${userId}`
);
res.json({ success: true, deleted: result.count });
} catch (error) {
console.error("Clear downloads error:", error);
logger.error("Clear downloads error:", error);
res.status(500).json({ error: "Failed to clear downloads" });
}
});
@@ -355,7 +517,7 @@ router.post("/clear-lidarr-queue", async (req, res) => {
errors: result.errors,
});
} catch (error: any) {
console.error("Clear Lidarr queue error:", error);
logger.error("Clear Lidarr queue error:", error);
res.status(500).json({ error: "Failed to clear Lidarr queue" });
}
});
@@ -373,7 +535,7 @@ router.get("/failed", async (req, res) => {
res.json(failedAlbums);
} catch (error) {
console.error("List failed albums error:", error);
logger.error("List failed albums error:", error);
res.status(500).json({ error: "Failed to list failed albums" });
}
});
@@ -399,7 +561,7 @@ router.delete("/failed/:id", async (req, res) => {
res.json({ success: true });
} catch (error) {
console.error("Delete failed album error:", error);
logger.error("Delete failed album error:", error);
res.status(500).json({ error: "Failed to delete failed album" });
}
});
@@ -423,7 +585,7 @@ router.get("/:id", async (req, res) => {
res.json(job);
} catch (error) {
console.error("Get download job error:", error);
logger.error("Get download job error:", error);
res.status(500).json({ error: "Failed to get download job" });
}
});
@@ -456,7 +618,7 @@ router.patch("/:id", async (req, res) => {
res.json(updated);
} catch (error) {
console.error("Update download job error:", error);
logger.error("Update download job error:", error);
res.status(500).json({ error: "Failed to update download job" });
}
});
@@ -479,8 +641,8 @@ router.delete("/:id", async (req, res) => {
// Return success even if nothing was deleted (idempotent delete)
res.json({ success: true, deleted: result.count > 0 });
} catch (error: any) {
console.error("Delete download job error:", error);
console.error("Error details:", error.message, error.stack);
logger.error("Delete download job error:", error);
logger.error("Error details:", error.message, error.stack);
res.status(500).json({
error: "Failed to delete download job",
details: error.message,
@@ -492,7 +654,12 @@ router.delete("/:id", async (req, res) => {
router.get("/", async (req, res) => {
try {
const userId = req.user!.id;
const { status, limit = "50", includeDiscovery = "false", includeCleared = "false" } = req.query;
const {
status,
limit = "50",
includeDiscovery = "false",
includeCleared = "false",
} = req.query;
const where: any = { userId };
if (status) {
@@ -521,7 +688,7 @@ router.get("/", async (req, res) => {
res.json(filteredJobs);
} catch (error) {
console.error("List download jobs error:", error);
logger.error("List download jobs error:", error);
res.status(500).json({ error: "Failed to list download jobs" });
}
});
@@ -580,7 +747,7 @@ router.post("/keep-track", async (req, res) => {
"Track marked as kept. Please add the full album manually to your /music folder.",
});
} catch (error) {
console.error("Keep track error:", error);
logger.error("Keep track error:", error);
res.status(500).json({ error: "Failed to keep track" });
}
});
+794 -62
View File
@@ -1,7 +1,19 @@
import { Router } from "express";
import { logger } from "../utils/logger";
import { requireAuth, requireAdmin } from "../middleware/auth";
import { enrichmentService } from "../services/enrichment";
import { getEnrichmentProgress, runFullEnrichment } from "../workers/unifiedEnrichment";
import {
getEnrichmentProgress,
runFullEnrichment,
} from "../workers/unifiedEnrichment";
import { enrichmentStateService } from "../services/enrichmentState";
import { enrichmentFailureService } from "../services/enrichmentFailureService";
import {
getSystemSettings,
invalidateSystemSettingsCache,
} from "../utils/systemSettings";
import { rateLimiter } from "../services/rateLimiter";
import { redisClient } from "../utils/redis";
const router = Router();
@@ -16,11 +28,82 @@ router.get("/progress", async (req, res) => {
const progress = await getEnrichmentProgress();
res.json(progress);
} catch (error) {
console.error("Get enrichment progress error:", error);
logger.error("Get enrichment progress error:", error);
res.status(500).json({ error: "Failed to get progress" });
}
});
/**
* GET /enrichment/status
* Get detailed enrichment state (running, paused, etc.)
*/
router.get("/status", async (req, res) => {
try {
const state = await enrichmentStateService.getState();
res.json(state || { status: "idle", currentPhase: null });
} catch (error) {
logger.error("Get enrichment status error:", error);
res.status(500).json({ error: "Failed to get status" });
}
});
/**
* POST /enrichment/pause
* Pause the enrichment process
*/
router.post("/pause", requireAdmin, async (req, res) => {
try {
const state = await enrichmentStateService.pause();
res.json({
message: "Enrichment paused",
state,
});
} catch (error: any) {
logger.error("Pause enrichment error:", error);
res.status(400).json({
error: error.message || "Failed to pause enrichment",
});
}
});
/**
* POST /enrichment/resume
* Resume a paused enrichment process
*/
router.post("/resume", requireAdmin, async (req, res) => {
try {
const state = await enrichmentStateService.resume();
res.json({
message: "Enrichment resumed",
state,
});
} catch (error: any) {
logger.error("Resume enrichment error:", error);
res.status(400).json({
error: error.message || "Failed to resume enrichment",
});
}
});
/**
* POST /enrichment/stop
* Stop the enrichment process
*/
router.post("/stop", requireAdmin, async (req, res) => {
try {
const state = await enrichmentStateService.stop();
res.json({
message: "Enrichment stopping...",
state,
});
} catch (error: any) {
logger.error("Stop enrichment error:", error);
res.status(400).json({
error: error.message || "Failed to stop enrichment",
});
}
});
/**
* POST /enrichment/full
* Trigger full enrichment (re-enriches everything regardless of status)
@@ -29,20 +112,48 @@ router.get("/progress", async (req, res) => {
router.post("/full", requireAdmin, async (req, res) => {
try {
// This runs in the background
runFullEnrichment().catch(err => {
console.error("Full enrichment error:", err);
runFullEnrichment().catch((err) => {
logger.error("Full enrichment error:", err);
});
res.json({
res.json({
message: "Full enrichment started",
description: "All artists, track tags, and audio analysis will be re-processed"
description:
"All artists, track tags, and audio analysis will be re-processed",
});
} catch (error) {
console.error("Trigger full enrichment error:", error);
logger.error("Trigger full enrichment error:", error);
res.status(500).json({ error: "Failed to start full enrichment" });
}
});
/**
* POST /enrichment/sync
* Trigger incremental enrichment (only processes pending items)
* Fast sync that picks up new content without re-processing everything
*/
router.post("/sync", async (req, res) => {
try {
const { triggerEnrichmentNow } = await import(
"../workers/unifiedEnrichment"
);
// Trigger immediate enrichment cycle (incremental mode)
const result = await triggerEnrichmentNow();
res.json({
message: "Incremental sync started",
description: "Processing new and pending items only",
result,
});
} catch (error: any) {
logger.error("Trigger sync error:", error);
res.status(500).json({
error: error.message || "Failed to start sync",
});
}
});
/**
* GET /enrichment/settings
* Get enrichment settings for current user
@@ -53,7 +164,7 @@ router.get("/settings", async (req, res) => {
const settings = await enrichmentService.getSettings(userId);
res.json(settings);
} catch (error) {
console.error("Get enrichment settings error:", error);
logger.error("Get enrichment settings error:", error);
res.status(500).json({ error: "Failed to get settings" });
}
});
@@ -65,10 +176,13 @@ router.get("/settings", async (req, res) => {
router.put("/settings", async (req, res) => {
try {
const userId = req.user!.id;
const settings = await enrichmentService.updateSettings(userId, req.body);
const settings = await enrichmentService.updateSettings(
userId,
req.body
);
res.json(settings);
} catch (error) {
console.error("Update enrichment settings error:", error);
logger.error("Update enrichment settings error:", error);
res.status(500).json({ error: "Failed to update settings" });
}
});
@@ -86,14 +200,20 @@ router.post("/artist/:id", async (req, res) => {
return res.status(400).json({ error: "Enrichment is not enabled" });
}
const enrichmentData = await enrichmentService.enrichArtist(req.params.id, settings);
const enrichmentData = await enrichmentService.enrichArtist(
req.params.id,
settings
);
if (!enrichmentData) {
return res.status(404).json({ error: "No enrichment data found" });
}
if (enrichmentData.confidence > 0.3) {
await enrichmentService.applyArtistEnrichment(req.params.id, enrichmentData);
await enrichmentService.applyArtistEnrichment(
req.params.id,
enrichmentData
);
}
res.json({
@@ -102,8 +222,10 @@ router.post("/artist/:id", async (req, res) => {
data: enrichmentData,
});
} catch (error: any) {
console.error("Enrich artist error:", error);
res.status(500).json({ error: error.message || "Failed to enrich artist" });
logger.error("Enrich artist error:", error);
res.status(500).json({
error: error.message || "Failed to enrich artist",
});
}
});
@@ -120,14 +242,20 @@ router.post("/album/:id", async (req, res) => {
return res.status(400).json({ error: "Enrichment is not enabled" });
}
const enrichmentData = await enrichmentService.enrichAlbum(req.params.id, settings);
const enrichmentData = await enrichmentService.enrichAlbum(
req.params.id,
settings
);
if (!enrichmentData) {
return res.status(404).json({ error: "No enrichment data found" });
}
if (enrichmentData.confidence > 0.3) {
await enrichmentService.applyAlbumEnrichment(req.params.id, enrichmentData);
await enrichmentService.applyAlbumEnrichment(
req.params.id,
enrichmentData
);
}
res.json({
@@ -136,8 +264,10 @@ router.post("/album/:id", async (req, res) => {
data: enrichmentData,
});
} catch (error: any) {
console.error("Enrich album error:", error);
res.status(500).json({ error: error.message || "Failed to enrich album" });
logger.error("Enrich album error:", error);
res.status(500).json({
error: error.message || "Failed to enrich album",
});
}
});
@@ -148,7 +278,9 @@ router.post("/album/:id", async (req, res) => {
router.post("/start", async (req, res) => {
try {
const userId = req.user!.id;
const { notificationService } = await import("../services/notificationService");
const { notificationService } = await import(
"../services/notificationService"
);
// Check if enrichment is enabled in system settings
const { prisma } = await import("../utils/db");
@@ -158,7 +290,9 @@ router.post("/start", async (req, res) => {
});
if (!systemSettings?.autoEnrichMetadata) {
return res.status(400).json({ error: "Enrichment is not enabled. Enable it in settings first." });
return res.status(400).json({
error: "Enrichment is not enabled. Enable it in settings first.",
});
}
// Get user enrichment settings or use defaults
@@ -175,50 +309,282 @@ router.post("/start", async (req, res) => {
);
// Start enrichment in background
enrichmentService.enrichLibrary(userId).then(async () => {
// Send notification when complete
await notificationService.notifySystem(
userId,
"Library Enrichment Complete",
"All artist metadata has been enriched"
);
}).catch(async (error) => {
console.error("Background enrichment failed:", error);
await notificationService.create({
userId,
type: "error",
title: "Enrichment Failed",
message: error.message || "Failed to enrich library metadata",
enrichmentService
.enrichLibrary(userId)
.then(async () => {
// Send notification when complete
await notificationService.notifySystem(
userId,
"Library Enrichment Complete",
"All artist metadata has been enriched"
);
})
.catch(async (error) => {
logger.error("Background enrichment failed:", error);
await notificationService.create({
userId,
type: "error",
title: "Enrichment Failed",
message:
error.message || "Failed to enrich library metadata",
});
});
});
res.json({
success: true,
message: "Library enrichment started in background",
});
} catch (error: any) {
console.error("Start enrichment error:", error);
res.status(500).json({ error: error.message || "Failed to start enrichment" });
logger.error("Start enrichment error:", error);
res.status(500).json({
error: error.message || "Failed to start enrichment",
});
}
});
/**
* PUT /library/artists/:id/metadata
* Update artist metadata manually
* GET /enrichment/failures
* Get all enrichment failures with filtering
*/
router.get("/failures", async (req, res) => {
try {
const { entityType, includeSkipped, includeResolved, limit, offset } =
req.query;
const options: any = {};
if (entityType) options.entityType = entityType as string;
if (includeSkipped === "true") options.includeSkipped = true;
if (includeResolved === "true") options.includeResolved = true;
if (limit) options.limit = parseInt(limit as string);
if (offset) options.offset = parseInt(offset as string);
const result = await enrichmentFailureService.getFailures(options);
res.json(result);
} catch (error) {
logger.error("Get failures error:", error);
res.status(500).json({ error: "Failed to get failures" });
}
});
/**
* GET /enrichment/failures/counts
* Get failure counts by type
*/
router.get("/failures/counts", async (req, res) => {
try {
const counts = await enrichmentFailureService.getFailureCounts();
res.json(counts);
} catch (error) {
logger.error("Get failure counts error:", error);
res.status(500).json({ error: "Failed to get failure counts" });
}
});
/**
* POST /enrichment/retry
* Retry specific failed items
*/
router.post("/retry", requireAdmin, async (req, res) => {
try {
const { ids } = req.body;
if (!ids || !Array.isArray(ids) || ids.length === 0) {
return res
.status(400)
.json({ error: "Must provide array of failure IDs" });
}
// Reset retry count for these failures
await enrichmentFailureService.resetRetryCount(ids);
// Get the failures to determine what to retry
const failures = await Promise.all(
ids.map((id) => enrichmentFailureService.getFailure(id))
);
// Group by type and trigger appropriate re-enrichment
const { prisma } = await import("../utils/db");
let queued = 0;
let skipped = 0;
for (const failure of failures) {
if (!failure) continue;
try {
if (failure.entityType === "artist") {
// Check if artist still exists
const artist = await prisma.artist.findUnique({
where: { id: failure.entityId },
select: { id: true },
});
if (!artist) {
// Entity was deleted - mark failure as resolved
await enrichmentFailureService.resolveFailures([
failure.id,
]);
skipped++;
continue;
}
// Reset artist enrichment status
await prisma.artist.update({
where: { id: failure.entityId },
data: { enrichmentStatus: "pending" },
});
queued++;
} else if (failure.entityType === "track") {
// Check if track still exists
const track = await prisma.track.findUnique({
where: { id: failure.entityId },
select: { id: true },
});
if (!track) {
// Entity was deleted - mark failure as resolved
await enrichmentFailureService.resolveFailures([
failure.id,
]);
skipped++;
continue;
}
// Reset track tag status
await prisma.track.update({
where: { id: failure.entityId },
data: { lastfmTags: [] },
});
queued++;
} else if (failure.entityType === "audio") {
// Check if track still exists
const track = await prisma.track.findUnique({
where: { id: failure.entityId },
select: { id: true },
});
if (!track) {
// Entity was deleted - mark failure as resolved
await enrichmentFailureService.resolveFailures([
failure.id,
]);
skipped++;
continue;
}
// Reset audio analysis status
await prisma.track.update({
where: { id: failure.entityId },
data: {
analysisStatus: "pending",
analysisRetryCount: 0,
},
});
queued++;
}
} catch (error) {
logger.error(
`Failed to reset ${failure.entityType} ${failure.entityId}:`,
error
);
// Don't re-throw - continue processing other failures
}
}
res.json({
message: `Queued ${queued} items for retry, ${skipped} skipped (entities no longer exist)`,
queued,
skipped,
});
} catch (error: any) {
logger.error("Retry failures error:", error);
res.status(500).json({
error: error.message || "Failed to retry failures",
});
}
});
/**
* POST /enrichment/skip
* Skip specific failures (won't retry automatically)
*/
router.post("/skip", requireAdmin, async (req, res) => {
try {
const { ids } = req.body;
if (!ids || !Array.isArray(ids) || ids.length === 0) {
return res
.status(400)
.json({ error: "Must provide array of failure IDs" });
}
const count = await enrichmentFailureService.skipFailures(ids);
res.json({
message: `Skipped ${count} failures`,
count,
});
} catch (error: any) {
logger.error("Skip failures error:", error);
res.status(500).json({
error: error.message || "Failed to skip failures",
});
}
});
/**
* DELETE /enrichment/failures/:id
* Delete a specific failure record
*/
router.delete("/failures/:id", requireAdmin, async (req, res) => {
try {
const count = await enrichmentFailureService.deleteFailures([
req.params.id,
]);
res.json({
message: "Failure deleted",
count,
});
} catch (error: any) {
logger.error("Delete failure error:", error);
res.status(500).json({
error: error.message || "Failed to delete failure",
});
}
});
/**
* PUT /enrichment/artists/:id/metadata
* Update artist metadata manually (non-destructive overrides)
* User edits are stored as overrides; canonical data preserved for API lookups
*/
router.put("/artists/:id/metadata", async (req, res) => {
try {
const { name, bio, genres, mbid, heroUrl } = req.body;
const { name, bio, genres, heroUrl } = req.body;
const updateData: any = {};
if (name) updateData.name = name;
if (bio) updateData.summary = bio;
if (mbid) updateData.mbid = mbid;
if (heroUrl) updateData.heroUrl = heroUrl;
if (genres) updateData.manualGenres = JSON.stringify(genres);
let hasOverrides = false;
// Mark as manually edited
updateData.manuallyEdited = true;
// Map user edits to override fields (non-destructive)
if (name !== undefined) {
updateData.displayName = name;
hasOverrides = true;
}
if (bio !== undefined) {
updateData.userSummary = bio;
hasOverrides = true;
}
if (heroUrl !== undefined) {
updateData.userHeroUrl = heroUrl;
hasOverrides = true;
}
if (genres !== undefined) {
updateData.userGenres = genres;
hasOverrides = true;
}
// Set override flag
if (hasOverrides) {
updateData.hasUserOverrides = true;
}
const { prisma } = await import("../utils/db");
const artist = await prisma.artist.update({
@@ -236,30 +602,56 @@ router.put("/artists/:id/metadata", async (req, res) => {
},
});
// Invalidate Redis cache for artist hero image
try {
await redisClient.del(`hero:${req.params.id}`);
} catch (err) {
logger.warn("Failed to invalidate Redis cache:", err);
}
res.json(artist);
} catch (error: any) {
console.error("Update artist metadata error:", error);
res.status(500).json({ error: error.message || "Failed to update artist" });
logger.error("Update artist metadata error:", error);
res.status(500).json({
error: error.message || "Failed to update artist",
});
}
});
/**
* PUT /library/albums/:id/metadata
* Update album metadata manually
* PUT /enrichment/albums/:id/metadata
* Update album metadata manually (non-destructive overrides)
* User edits are stored as overrides; canonical data preserved for API lookups
*/
router.put("/albums/:id/metadata", async (req, res) => {
try {
const { title, year, genres, rgMbid, coverUrl } = req.body;
const { title, year, genres, coverUrl } = req.body;
const updateData: any = {};
if (title) updateData.title = title;
if (year) updateData.year = parseInt(year);
if (rgMbid) updateData.rgMbid = rgMbid;
if (coverUrl) updateData.coverUrl = coverUrl;
if (genres) updateData.manualGenres = JSON.stringify(genres);
let hasOverrides = false;
// Mark as manually edited
updateData.manuallyEdited = true;
// Map user edits to override fields (non-destructive)
if (title !== undefined) {
updateData.displayTitle = title;
hasOverrides = true;
}
if (year !== undefined) {
updateData.displayYear = parseInt(year);
hasOverrides = true;
}
if (coverUrl !== undefined) {
updateData.userCoverUrl = coverUrl;
hasOverrides = true;
}
if (genres !== undefined) {
updateData.userGenres = genres;
hasOverrides = true;
}
// Set override flag
if (hasOverrides) {
updateData.hasUserOverrides = true;
}
const { prisma } = await import("../utils/db");
const album = await prisma.album.update({
@@ -285,8 +677,348 @@ router.put("/albums/:id/metadata", async (req, res) => {
res.json(album);
} catch (error: any) {
console.error("Update album metadata error:", error);
res.status(500).json({ error: error.message || "Failed to update album" });
logger.error("Update album metadata error:", error);
res.status(500).json({
error: error.message || "Failed to update album",
});
}
});
/**
* PUT /enrichment/tracks/:id/metadata
* Update track metadata manually (non-destructive overrides)
* User edits are stored as overrides; canonical data preserved
*/
router.put("/tracks/:id/metadata", async (req, res) => {
try {
const { title, trackNo } = req.body;
const updateData: any = {};
let hasOverrides = false;
// Map user edits to override fields (non-destructive)
if (title !== undefined) {
updateData.displayTitle = title;
hasOverrides = true;
}
if (trackNo !== undefined) {
updateData.displayTrackNo = parseInt(trackNo);
hasOverrides = true;
}
// Set override flag
if (hasOverrides) {
updateData.hasUserOverrides = true;
}
const { prisma } = await import("../utils/db");
const track = await prisma.track.update({
where: { id: req.params.id },
data: updateData,
include: {
album: {
select: {
id: true,
title: true,
artist: {
select: {
id: true,
name: true,
},
},
},
},
},
});
res.json(track);
} catch (error: any) {
logger.error("Update track metadata error:", error);
res.status(500).json({
error: error.message || "Failed to update track",
});
}
});
/**
* POST /enrichment/artists/:id/reset
* Reset artist metadata to canonical values (clear all user overrides)
*/
router.post("/artists/:id/reset", async (req, res) => {
try {
const { prisma } = await import("../utils/db");
// Check if artist exists first
const existingArtist = await prisma.artist.findUnique({
where: { id: req.params.id },
select: { id: true },
});
if (!existingArtist) {
return res.status(404).json({
error: "Artist not found",
message: "The artist may have been deleted",
});
}
const artist = await prisma.artist.update({
where: { id: req.params.id },
data: {
displayName: null,
userSummary: null,
userHeroUrl: null,
userGenres: [],
hasUserOverrides: false,
},
include: {
albums: {
select: {
id: true,
title: true,
year: true,
coverUrl: true,
},
},
},
});
// Invalidate Redis cache for artist hero image
try {
await redisClient.del(`hero:${req.params.id}`);
} catch (err) {
logger.warn("Failed to invalidate Redis cache:", err);
}
res.json({
message: "Artist metadata reset to original values",
artist,
});
} catch (error: any) {
// Handle P2025 specifically in case of race condition
if (error.code === "P2025") {
return res.status(404).json({
error: "Artist not found",
message: "The artist may have been deleted",
});
}
logger.error("Reset artist metadata error:", error);
res.status(500).json({
error: error.message || "Failed to reset artist metadata",
});
}
});
/**
* POST /enrichment/albums/:id/reset
* Reset album metadata to canonical values (clear all user overrides)
*/
router.post("/albums/:id/reset", async (req, res) => {
try {
const { prisma } = await import("../utils/db");
// Check if album exists first
const existingAlbum = await prisma.album.findUnique({
where: { id: req.params.id },
select: { id: true },
});
if (!existingAlbum) {
return res.status(404).json({
error: "Album not found",
message: "The album may have been deleted",
});
}
const album = await prisma.album.update({
where: { id: req.params.id },
data: {
displayTitle: null,
displayYear: null,
userCoverUrl: null,
userGenres: [],
hasUserOverrides: false,
},
include: {
artist: {
select: {
id: true,
name: true,
},
},
tracks: {
select: {
id: true,
title: true,
trackNo: true,
duration: true,
},
},
},
});
res.json({
message: "Album metadata reset to original values",
album,
});
} catch (error: any) {
// Handle P2025 specifically in case of race condition
if (error.code === "P2025") {
return res.status(404).json({
error: "Album not found",
message: "The album may have been deleted",
});
}
logger.error("Reset album metadata error:", error);
res.status(500).json({
error: error.message || "Failed to reset album metadata",
});
}
});
/**
* POST /enrichment/tracks/:id/reset
* Reset track metadata to canonical values (clear all user overrides)
*/
router.post("/tracks/:id/reset", async (req, res) => {
try {
const { prisma } = await import("../utils/db");
// Check if track exists first
const existingTrack = await prisma.track.findUnique({
where: { id: req.params.id },
select: { id: true },
});
if (!existingTrack) {
return res.status(404).json({
error: "Track not found",
message: "The track may have been deleted",
});
}
const track = await prisma.track.update({
where: { id: req.params.id },
data: {
displayTitle: null,
displayTrackNo: null,
hasUserOverrides: false,
},
include: {
album: {
select: {
id: true,
title: true,
artist: {
select: {
id: true,
name: true,
},
},
},
},
},
});
res.json({
message: "Track metadata reset to original values",
track,
});
} catch (error: any) {
// Handle P2025 specifically in case of race condition
if (error.code === "P2025") {
return res.status(404).json({
error: "Track not found",
message: "The track may have been deleted",
});
}
logger.error("Reset track metadata error:", error);
res.status(500).json({
error: error.message || "Failed to reset track metadata",
});
}
});
/**
* GET /enrichment/concurrency
* Get current enrichment concurrency configuration
*/
router.get("/concurrency", async (req, res) => {
try {
const settings = await getSystemSettings();
const concurrency = settings?.enrichmentConcurrency || 1;
// Calculate estimated speeds based on concurrency
const artistsPerMin = Math.round(10 * concurrency);
const tracksPerMin = Math.round(60 * concurrency);
res.json({
concurrency,
estimatedSpeed: `~${artistsPerMin} artists/min, ~${tracksPerMin} tracks/min`,
artistsPerMin,
tracksPerMin,
});
} catch (error) {
logger.error("Failed to get enrichment settings:", error);
res.status(500).json({ error: "Failed to get enrichment settings" });
}
});
/**
* PUT /enrichment/concurrency
* Update enrichment concurrency configuration
*/
router.put("/concurrency", requireAdmin, async (req, res) => {
try {
const { concurrency } = req.body;
if (!concurrency || typeof concurrency !== "number") {
return res
.status(400)
.json({ error: "Missing or invalid 'concurrency' parameter" });
}
// Clamp concurrency to 1-5
const clampedConcurrency = Math.max(
1,
Math.min(5, Math.floor(concurrency))
);
// Update system settings in database
const { prisma } = await import("../utils/db");
await prisma.systemSettings.upsert({
where: { id: "default" },
create: {
id: "default",
enrichmentConcurrency: clampedConcurrency,
},
update: {
enrichmentConcurrency: clampedConcurrency,
},
});
// Invalidate cache so next read gets fresh value
invalidateSystemSettingsCache();
// Update rate limiter concurrency multiplier
rateLimiter.updateConcurrencyMultiplier(clampedConcurrency);
// Calculate estimated speeds
const artistsPerMin = Math.round(10 * clampedConcurrency);
const tracksPerMin = Math.round(60 * clampedConcurrency);
logger.debug(
`[Enrichment Settings] Updated concurrency to ${clampedConcurrency}`
);
res.json({
concurrency: clampedConcurrency,
estimatedSpeed: `~${artistsPerMin} artists/min, ~${tracksPerMin} tracks/min`,
artistsPerMin,
tracksPerMin,
});
} catch (error) {
logger.error("Failed to update enrichment settings:", error);
res.status(500).json({ error: "Failed to update enrichment settings" });
}
});
+25 -18
View File
@@ -1,6 +1,7 @@
import { Router } from "express";
import { logger } from "../utils/logger";
import { requireAuthOrToken } from "../middleware/auth";
import { prisma } from "../utils/db";
import { prisma, Prisma } from "../utils/db";
import { redisClient } from "../utils/redis";
const router = Router();
@@ -22,14 +23,14 @@ router.get("/genres", async (req, res) => {
try {
const cached = await redisClient.get(cacheKey);
if (cached) {
console.log(`[HOMEPAGE] Cache HIT for genres`);
logger.debug(`[HOMEPAGE] Cache HIT for genres`);
return res.json(JSON.parse(cached));
}
} catch (cacheError) {
console.warn("[HOMEPAGE] Redis cache read error:", cacheError);
logger.warn("[HOMEPAGE] Redis cache read error:", cacheError);
}
console.log(
logger.debug(
`[HOMEPAGE] ✗ Cache MISS for genres, fetching from database...`
);
@@ -37,7 +38,7 @@ router.get("/genres", async (req, res) => {
const albums = await prisma.album.findMany({
where: {
genres: {
isEmpty: false, // Only albums with genres
not: Prisma.JsonNull, // Only albums with genres (not null)
},
location: "LIBRARY", // Exclude discovery albums
},
@@ -60,8 +61,11 @@ router.get("/genres", async (req, res) => {
// Count genre occurrences
const genreCounts = new Map<string, number>();
for (const album of albums) {
for (const genre of album.genres) {
genreCounts.set(genre, (genreCounts.get(genre) || 0) + 1);
const genres = album.genres as string[];
if (genres && Array.isArray(genres)) {
for (const genre of genres) {
genreCounts.set(genre, (genreCounts.get(genre) || 0) + 1);
}
}
}
@@ -71,12 +75,15 @@ router.get("/genres", async (req, res) => {
.slice(0, limitNum)
.map(([genre]) => genre);
console.log(`[HOMEPAGE] Top genres: ${topGenres.join(", ")}`);
logger.debug(`[HOMEPAGE] Top genres: ${topGenres.join(", ")}`);
// For each top genre, get sample albums (up to 10)
const genresWithAlbums = topGenres.map((genre) => {
const genreAlbums = albums
.filter((a) => a.genres.includes(genre))
.filter((a) => {
const genres = a.genres as string[];
return genres && Array.isArray(genres) && genres.includes(genre);
})
.slice(0, 10)
.map((a) => ({
id: a.id,
@@ -103,14 +110,14 @@ router.get("/genres", async (req, res) => {
24 * 60 * 60,
JSON.stringify(genresWithAlbums)
);
console.log(`[HOMEPAGE] Cached genres for 24 hours`);
logger.debug(`[HOMEPAGE] Cached genres for 24 hours`);
} catch (cacheError) {
console.warn("[HOMEPAGE] Redis cache write error:", cacheError);
logger.warn("[HOMEPAGE] Redis cache write error:", cacheError);
}
res.json(genresWithAlbums);
} catch (error) {
console.error("Get homepage genres error:", error);
logger.error("Get homepage genres error:", error);
res.status(500).json({ error: "Failed to fetch genres" });
}
});
@@ -129,14 +136,14 @@ router.get("/top-podcasts", async (req, res) => {
try {
const cached = await redisClient.get(cacheKey);
if (cached) {
console.log(`[HOMEPAGE] Cache HIT for top podcasts`);
logger.debug(`[HOMEPAGE] Cache HIT for top podcasts`);
return res.json(JSON.parse(cached));
}
} catch (cacheError) {
console.warn("[HOMEPAGE] Redis cache read error:", cacheError);
logger.warn("[HOMEPAGE] Redis cache read error:", cacheError);
}
console.log(
logger.debug(
`[HOMEPAGE] ✗ Cache MISS for top podcasts, fetching from database...`
);
@@ -172,14 +179,14 @@ router.get("/top-podcasts", async (req, res) => {
24 * 60 * 60,
JSON.stringify(result)
);
console.log(`[HOMEPAGE] Cached top podcasts for 24 hours`);
logger.debug(`[HOMEPAGE] Cached top podcasts for 24 hours`);
} catch (cacheError) {
console.warn("[HOMEPAGE] Redis cache write error:", cacheError);
logger.warn("[HOMEPAGE] Redis cache write error:", cacheError);
}
res.json(result);
} catch (error) {
console.error("Get top podcasts error:", error);
logger.error("Get top podcasts error:", error);
res.status(500).json({ error: "Failed to fetch top podcasts" });
}
});
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -1,4 +1,5 @@
import { Router } from "express";
import { logger } from "../utils/logger";
import { requireAuth } from "../middleware/auth";
import { prisma } from "../utils/db";
import { z } from "zod";
@@ -46,7 +47,7 @@ router.post("/", async (req, res) => {
.status(400)
.json({ error: "Invalid request", details: error.errors });
}
console.error("Update listening state error:", error);
logger.error("Update listening state error:", error);
res.status(500).json({ error: "Failed to update listening state" });
}
});
@@ -79,7 +80,7 @@ router.get("/", async (req, res) => {
res.json(state);
} catch (error) {
console.error("Get listening state error:", error);
logger.error("Get listening state error:", error);
res.status(500).json({ error: "Failed to get listening state" });
}
});
@@ -98,7 +99,7 @@ router.get("/recent", async (req, res) => {
res.json(states);
} catch (error) {
console.error("Get recent listening states error:", error);
logger.error("Get recent listening states error:", error);
res.status(500).json({
error: "Failed to get recent listening states",
});
+18 -18
View File
@@ -1,5 +1,6 @@
import { Router } from "express";
import { requireAuthOrToken } from "../middleware/auth";
import { logger } from "../utils/logger";
import { requireAuthOrToken, requireAdmin } from "../middleware/auth";
import { programmaticPlaylistService } from "../services/programmaticPlaylists";
import {
moodBucketService,
@@ -93,7 +94,7 @@ router.get("/", async (req, res) => {
res.json(mixes);
} catch (error) {
console.error("Get mixes error:", error);
logger.error("Get mixes error:", error);
res.status(500).json({ error: "Failed to get mixes" });
}
});
@@ -252,7 +253,7 @@ router.post("/mood", async (req, res) => {
.map((id: string) => tracks.find((t) => t.id === id))
.filter((t: any) => t !== undefined);
console.log(
logger.debug(
`[MIXES] Generated mood-on-demand mix with ${mix.trackCount} tracks`
);
@@ -261,7 +262,7 @@ router.post("/mood", async (req, res) => {
tracks: orderedTracks,
});
} catch (error) {
console.error("Generate mood mix error:", error);
logger.error("Generate mood mix error:", error);
res.status(500).json({ error: "Failed to generate mood mix" });
}
});
@@ -430,11 +431,11 @@ router.post("/mood/save-preferences", async (req, res) => {
const cacheKey = `mixes:${userId}`;
await redisClient.del(cacheKey);
console.log(`[MIXES] Saved mood mix preferences for user ${userId}`);
logger.debug(`[MIXES] Saved mood mix preferences for user ${userId}`);
res.json({ success: true, message: "Mood preferences saved" });
} catch (error) {
console.error("Save mood preferences error:", error);
logger.error("Save mood preferences error:", error);
res.status(500).json({ error: "Failed to save mood preferences" });
}
});
@@ -462,7 +463,7 @@ router.get("/mood/buckets/presets", async (req, res) => {
const presets = await moodBucketService.getMoodPresets();
res.json(presets);
} catch (error) {
console.error("Get mood presets error:", error);
logger.error("Get mood presets error:", error);
res.status(500).json({ error: "Failed to get mood presets" });
}
});
@@ -535,7 +536,7 @@ router.get("/mood/buckets/:mood", async (req, res) => {
tracks: orderedTracks,
});
} catch (error) {
console.error("Get mood bucket mix error:", error);
logger.error("Get mood bucket mix error:", error);
res.status(500).json({ error: "Failed to get mood mix" });
}
});
@@ -611,7 +612,7 @@ router.post("/mood/buckets/:mood/save", async (req, res) => {
.map((id: string) => tracks.find((t) => t.id === id))
.filter((t: any) => t !== undefined);
console.log(
logger.debug(
`[MIXES] Saved mood bucket mix for user ${userId}: ${mood} (${savedMix.trackCount} tracks)`
);
@@ -623,7 +624,7 @@ router.post("/mood/buckets/:mood/save", async (req, res) => {
},
});
} catch (error) {
console.error("Save mood bucket mix error:", error);
logger.error("Save mood bucket mix error:", error);
res.status(500).json({ error: "Failed to save mood mix" });
}
});
@@ -642,15 +643,14 @@ router.post("/mood/buckets/:mood/save", async (req, res) => {
* 200:
* description: Backfill completed
*/
router.post("/mood/buckets/backfill", async (req, res) => {
router.post("/mood/buckets/backfill", requireAdmin, async (req, res) => {
try {
const userId = getRequestUserId(req);
if (!userId) {
return res.status(401).json({ error: "Not authenticated" });
}
// TODO: Add admin check
console.log(
logger.debug(
`[MIXES] Starting mood bucket backfill requested by user ${userId}`
);
@@ -662,7 +662,7 @@ router.post("/mood/buckets/backfill", async (req, res) => {
assigned: result.assigned,
});
} catch (error) {
console.error("Backfill mood buckets error:", error);
logger.error("Backfill mood buckets error:", error);
res.status(500).json({ error: "Failed to backfill mood buckets" });
}
});
@@ -721,7 +721,7 @@ router.post("/refresh", async (req, res) => {
res.json({ message: "Mixes refreshed", mixes });
} catch (error) {
console.error("Refresh mixes error:", error);
logger.error("Refresh mixes error:", error);
res.status(500).json({ error: "Failed to refresh mixes" });
}
});
@@ -849,7 +849,7 @@ router.post("/:id/save", async (req, res) => {
data: playlistItems,
});
console.log(
logger.debug(
`[MIXES] Saved mix ${mixId} as playlist ${playlist.id} (${mix.trackIds.length} tracks)`
);
@@ -859,7 +859,7 @@ router.post("/:id/save", async (req, res) => {
trackCount: mix.trackIds.length,
});
} catch (error) {
console.error("Save mix as playlist error:", error);
logger.error("Save mix as playlist error:", error);
res.status(500).json({ error: "Failed to save mix as playlist" });
}
});
@@ -982,7 +982,7 @@ router.get("/:id", async (req, res) => {
tracks: orderedTracks,
});
} catch (error) {
console.error("Get mix error:", error);
logger.error("Get mix error:", error);
res.status(500).json({ error: "Failed to get mix" });
}
});
+51 -39
View File
@@ -1,6 +1,7 @@
import { Router, Response } from "express";
import { Router, Request, Response } from "express";
import { logger } from "../utils/logger";
import { notificationService } from "../services/notificationService";
import { AuthenticatedRequest, requireAuth } from "../middleware/auth";
import { requireAuth } from "../middleware/auth";
import { prisma } from "../utils/db";
const router = Router();
@@ -12,9 +13,9 @@ const router = Router();
router.get(
"/",
requireAuth,
async (req: AuthenticatedRequest, res: Response) => {
async (req: Request, res: Response) => {
try {
console.log(
logger.debug(
`[Notifications] Fetching notifications for user ${
req.user!.id
}`
@@ -22,12 +23,12 @@ router.get(
const notifications = await notificationService.getForUser(
req.user!.id
);
console.log(
logger.debug(
`[Notifications] Found ${notifications.length} notifications`
);
res.json(notifications);
} catch (error: any) {
console.error("Error fetching notifications:", error);
logger.error("Error fetching notifications:", error);
res.status(500).json({ error: "Failed to fetch notifications" });
}
}
@@ -40,14 +41,14 @@ router.get(
router.get(
"/unread-count",
requireAuth,
async (req: AuthenticatedRequest, res: Response) => {
async (req: Request, res: Response) => {
try {
const count = await notificationService.getUnreadCount(
req.user!.id
);
res.json({ count });
} catch (error: any) {
console.error("Error fetching unread count:", error);
logger.error("Error fetching unread count:", error);
res.status(500).json({ error: "Failed to fetch unread count" });
}
}
@@ -60,12 +61,12 @@ router.get(
router.post(
"/:id/read",
requireAuth,
async (req: AuthenticatedRequest, res: Response) => {
async (req: Request, res: Response) => {
try {
await notificationService.markAsRead(req.params.id, req.user!.id);
res.json({ success: true });
} catch (error: any) {
console.error("Error marking notification as read:", error);
logger.error("Error marking notification as read:", error);
res.status(500).json({
error: "Failed to mark notification as read",
});
@@ -80,12 +81,12 @@ router.post(
router.post(
"/read-all",
requireAuth,
async (req: AuthenticatedRequest, res: Response) => {
async (req: Request, res: Response) => {
try {
await notificationService.markAllAsRead(req.user!.id);
res.json({ success: true });
} catch (error: any) {
console.error("Error marking all notifications as read:", error);
logger.error("Error marking all notifications as read:", error);
res.status(500).json({
error: "Failed to mark all notifications as read",
});
@@ -100,12 +101,12 @@ router.post(
router.post(
"/:id/clear",
requireAuth,
async (req: AuthenticatedRequest, res: Response) => {
async (req: Request, res: Response) => {
try {
await notificationService.clear(req.params.id, req.user!.id);
res.json({ success: true });
} catch (error: any) {
console.error("Error clearing notification:", error);
logger.error("Error clearing notification:", error);
res.status(500).json({ error: "Failed to clear notification" });
}
}
@@ -118,12 +119,12 @@ router.post(
router.post(
"/clear-all",
requireAuth,
async (req: AuthenticatedRequest, res: Response) => {
async (req: Request, res: Response) => {
try {
await notificationService.clearAll(req.user!.id);
res.json({ success: true });
} catch (error: any) {
console.error("Error clearing all notifications:", error);
logger.error("Error clearing all notifications:", error);
res.status(500).json({
error: "Failed to clear all notifications",
});
@@ -138,11 +139,12 @@ router.post(
/**
* GET /notifications/downloads/history
* Get completed/failed downloads that haven't been cleared
* Deduplicated by album subject (shows only most recent entry per album)
*/
router.get(
"/downloads/history",
requireAuth,
async (req: AuthenticatedRequest, res: Response) => {
async (req: Request, res: Response) => {
try {
const downloads = await prisma.downloadJob.findMany({
where: {
@@ -151,11 +153,23 @@ router.get(
cleared: false,
},
orderBy: { updatedAt: "desc" },
take: 50,
take: 100, // Fetch more to account for duplicates
});
res.json(downloads);
// Deduplicate by subject - keep only the most recent entry per album
const seen = new Set<string>();
const deduplicated = downloads.filter((download) => {
if (seen.has(download.subject)) {
return false; // Skip duplicate
}
seen.add(download.subject);
return true; // Keep first occurrence (most recent due to ordering)
});
// Return top 50 after deduplication
res.json(deduplicated.slice(0, 50));
} catch (error: any) {
console.error("Error fetching download history:", error);
logger.error("Error fetching download history:", error);
res.status(500).json({ error: "Failed to fetch download history" });
}
}
@@ -168,7 +182,7 @@ router.get(
router.get(
"/downloads/active",
requireAuth,
async (req: AuthenticatedRequest, res: Response) => {
async (req: Request, res: Response) => {
try {
const downloads = await prisma.downloadJob.findMany({
where: {
@@ -179,7 +193,7 @@ router.get(
});
res.json(downloads);
} catch (error: any) {
console.error("Error fetching active downloads:", error);
logger.error("Error fetching active downloads:", error);
res.status(500).json({ error: "Failed to fetch active downloads" });
}
}
@@ -192,7 +206,7 @@ router.get(
router.post(
"/downloads/:id/clear",
requireAuth,
async (req: AuthenticatedRequest, res: Response) => {
async (req: Request, res: Response) => {
try {
await prisma.downloadJob.updateMany({
where: {
@@ -203,7 +217,7 @@ router.post(
});
res.json({ success: true });
} catch (error: any) {
console.error("Error clearing download:", error);
logger.error("Error clearing download:", error);
res.status(500).json({ error: "Failed to clear download" });
}
}
@@ -216,7 +230,7 @@ router.post(
router.post(
"/downloads/clear-all",
requireAuth,
async (req: AuthenticatedRequest, res: Response) => {
async (req: Request, res: Response) => {
try {
await prisma.downloadJob.updateMany({
where: {
@@ -228,7 +242,7 @@ router.post(
});
res.json({ success: true });
} catch (error: any) {
console.error("Error clearing all downloads:", error);
logger.error("Error clearing all downloads:", error);
res.status(500).json({ error: "Failed to clear all downloads" });
}
}
@@ -241,7 +255,7 @@ router.post(
router.post(
"/downloads/:id/retry",
requireAuth,
async (req: AuthenticatedRequest, res: Response) => {
async (req: Request, res: Response) => {
try {
// Get the failed download
const failedJob = await prisma.downloadJob.findFirst({
@@ -478,11 +492,9 @@ router.post(
const albumTitle = metadata.albumTitle as string;
if (!artistName || !albumTitle) {
return res
.status(400)
.json({
error: "Cannot retry: missing artist/album info",
});
return res.status(400).json({
error: "Cannot retry: missing artist/album info",
});
}
// Mark old job as cleared
@@ -546,13 +558,13 @@ router.post(
},
];
console.log(
logger.debug(
`[Retry] Trying Soulseek for ${artistName} - ${albumTitle}`
);
// Run Soulseek search async
soulseekService
.searchAndDownloadBatch(tracks, musicPath, 4)
.searchAndDownloadBatch(tracks, musicPath, settings?.soulseekConcurrentDownloads || 4)
.then(async (result) => {
if (result.successful > 0) {
await prisma.downloadJob.update({
@@ -569,7 +581,7 @@ router.post(
},
},
});
console.log(
logger.debug(
`[Retry] ✓ Soulseek downloaded ${result.successful} tracks for ${artistName} - ${albumTitle}`
);
@@ -585,7 +597,7 @@ router.post(
});
} else {
// Soulseek failed, try Lidarr if we have an MBID
console.log(
logger.debug(
`[Retry] Soulseek failed, trying Lidarr for ${artistName} - ${albumTitle}`
);
@@ -631,7 +643,7 @@ router.post(
}
})
.catch(async (error) => {
console.error(`[Retry] Soulseek error:`, error);
logger.error(`[Retry] Soulseek error:`, error);
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
@@ -676,7 +688,7 @@ router.post(
artistMbid: failedJob.artistMbid,
subject: failedJob.subject,
status: "pending",
metadata: metadata || {},
metadata: (metadata || {}) as any,
},
});
@@ -702,7 +714,7 @@ router.post(
error: result.error,
});
} catch (error: any) {
console.error("Error retrying download:", error);
logger.error("Error retrying download:", error);
res.status(500).json({ error: "Failed to retry download" });
}
}
+9 -8
View File
@@ -1,4 +1,5 @@
import { Router } from "express";
import { logger } from "../utils/logger";
import { requireAuth } from "../middleware/auth";
import { prisma } from "../utils/db";
import { z } from "zod";
@@ -19,12 +20,12 @@ router.post("/albums/:id/download", async (req, res) => {
const { quality } = downloadAlbumSchema.parse(req.body);
// Get user's default quality if not specified
let selectedQuality = quality;
if (!selectedQuality) {
let selectedQuality: "original" | "high" | "medium" | "low" = quality || "medium";
if (!quality) {
const settings = await prisma.userSettings.findUnique({
where: { userId },
});
selectedQuality = (settings?.playbackQuality as any) || "medium";
selectedQuality = (settings?.playbackQuality as "original" | "high" | "medium" | "low") || "medium";
}
// Get album with tracks
@@ -103,7 +104,7 @@ router.post("/albums/:id/download", async (req, res) => {
.status(400)
.json({ error: "Invalid request", details: error.errors });
}
console.error("Create download job error:", error);
logger.error("Create download job error:", error);
res.status(500).json({ error: "Failed to create download job" });
}
});
@@ -145,7 +146,7 @@ router.post("/tracks/:id/complete", async (req, res) => {
res.json(cachedTrack);
} catch (error) {
console.error("Complete track download error:", error);
logger.error("Complete track download error:", error);
res.status(500).json({ error: "Failed to complete download" });
}
});
@@ -209,7 +210,7 @@ router.get("/albums", async (req, res) => {
res.json(albums);
} catch (error) {
console.error("Get cached albums error:", error);
logger.error("Get cached albums error:", error);
res.status(500).json({ error: "Failed to get cached albums" });
}
});
@@ -245,7 +246,7 @@ router.delete("/albums/:id", async (req, res) => {
deletedCount: cachedTracks.length,
});
} catch (error) {
console.error("Delete cached album error:", error);
logger.error("Delete cached album error:", error);
res.status(500).json({ error: "Failed to delete cached album" });
}
});
@@ -278,7 +279,7 @@ router.get("/stats", async (req, res) => {
trackCount,
});
} catch (error) {
console.error("Get cache stats error:", error);
logger.error("Get cache stats error:", error);
res.status(500).json({ error: "Failed to get cache stats" });
}
});
+22 -20
View File
@@ -1,4 +1,5 @@
import { Router } from "express";
import { logger } from "../utils/logger";
import { prisma } from "../utils/db";
import bcrypt from "bcrypt";
import { z } from "zod";
@@ -49,14 +50,14 @@ async function ensureEncryptionKey(): Promise<void> {
process.env.SETTINGS_ENCRYPTION_KEY !==
"default-encryption-key-change-me"
) {
console.log("[ONBOARDING] Encryption key already exists");
logger.debug("[ONBOARDING] Encryption key already exists");
return;
}
// Generate a secure 32-byte encryption key
const encryptionKey = crypto.randomBytes(32).toString("base64");
console.log(
logger.debug(
"[ONBOARDING] Generating encryption key for settings security..."
);
@@ -69,9 +70,9 @@ async function ensureEncryptionKey(): Promise<void> {
// Update the process environment so it's available immediately
process.env.SETTINGS_ENCRYPTION_KEY = encryptionKey;
console.log("[ONBOARDING] Encryption key generated and saved to .env");
logger.debug("[ONBOARDING] Encryption key generated and saved to .env");
} catch (error) {
console.error("[ONBOARDING] Failed to save encryption key:", error);
logger.error("[ONBOARDING] Failed to save encryption key:", error);
throw new Error("Failed to generate encryption key");
}
}
@@ -82,7 +83,7 @@ async function ensureEncryptionKey(): Promise<void> {
*/
router.post("/register", async (req, res) => {
try {
console.log("[ONBOARDING] Register attempt for user:", req.body?.username);
logger.debug("[ONBOARDING] Register attempt for user:", req.body?.username);
const { username, password } = registerSchema.parse(req.body);
// Check if any user exists (first user becomes admin)
@@ -100,7 +101,7 @@ router.post("/register", async (req, res) => {
});
if (existing) {
console.log("[ONBOARDING] Username already taken:", username);
logger.debug("[ONBOARDING] Username already taken:", username);
return res.status(400).json({ error: "Username already taken" });
}
@@ -131,9 +132,10 @@ router.post("/register", async (req, res) => {
id: user.id,
username: user.username,
role: user.role,
tokenVersion: user.tokenVersion,
});
console.log("[ONBOARDING] User created successfully:", user.username);
logger.debug("[ONBOARDING] User created successfully:", user.username);
res.json({
token,
user: {
@@ -145,12 +147,12 @@ router.post("/register", async (req, res) => {
});
} catch (err: any) {
if (err instanceof z.ZodError) {
console.error("[ONBOARDING] Validation error:", err.errors);
logger.error("[ONBOARDING] Validation error:", err.errors);
return res
.status(400)
.json({ error: "Invalid request", details: err.errors });
}
console.error("Registration error:", err);
logger.error("Registration error:", err);
res.status(500).json({ error: "Failed to create account" });
}
});
@@ -189,10 +191,10 @@ router.post("/lidarr", requireAuth, async (req, res) => {
if (response.status === 200) {
connectionTested = true;
console.log("Lidarr connection test successful");
logger.debug("Lidarr connection test successful");
}
} catch (error: any) {
console.warn(
logger.warn(
" Lidarr connection test failed (saved anyway):",
error.message
);
@@ -229,7 +231,7 @@ router.post("/lidarr", requireAuth, async (req, res) => {
.status(400)
.json({ error: "Invalid request", details: err.errors });
}
console.error("Lidarr config error:", err);
logger.error("Lidarr config error:", err);
res.status(500).json({ error: "Failed to save configuration" });
}
});
@@ -265,10 +267,10 @@ router.post("/audiobookshelf", requireAuth, async (req, res) => {
if (response.status === 200) {
connectionTested = true;
console.log("Audiobookshelf connection test successful");
logger.debug("Audiobookshelf connection test successful");
}
} catch (error: any) {
console.warn(
logger.warn(
" Audiobookshelf connection test failed (saved anyway):",
error.message
);
@@ -305,7 +307,7 @@ router.post("/audiobookshelf", requireAuth, async (req, res) => {
.status(400)
.json({ error: "Invalid request", details: err.errors });
}
console.error("Audiobookshelf config error:", err);
logger.error("Audiobookshelf config error:", err);
res.status(500).json({ error: "Failed to save configuration" });
}
});
@@ -363,7 +365,7 @@ router.post("/soulseek", requireAuth, async (req, res) => {
.status(400)
.json({ error: "Invalid request", details: err.errors });
}
console.error("Soulseek config error:", err);
logger.error("Soulseek config error:", err);
res.status(500).json({ error: "Failed to save configuration" });
}
});
@@ -394,7 +396,7 @@ router.post("/enrichment", requireAuth, async (req, res) => {
.status(400)
.json({ error: "Invalid request", details: err.errors });
}
console.error("Enrichment config error:", err);
logger.error("Enrichment config error:", err);
res.status(500).json({ error: "Failed to save configuration" });
}
});
@@ -410,10 +412,10 @@ router.post("/complete", requireAuth, async (req, res) => {
data: { onboardingComplete: true },
});
console.log("[ONBOARDING] User completed onboarding:", req.user!.id);
logger.debug("[ONBOARDING] User completed onboarding:", req.user!.id);
res.json({ success: true });
} catch (err: any) {
console.error("Onboarding complete error:", err);
logger.error("Onboarding complete error:", err);
res.status(500).json({ error: "Failed to complete onboarding" });
}
});
@@ -467,7 +469,7 @@ router.get("/status", async (req, res) => {
});
}
} catch (err: any) {
console.error("Onboarding status error:", err);
logger.error("Onboarding status error:", err);
res.status(500).json({ error: "Failed to check status" });
}
});
+12 -11
View File
@@ -1,5 +1,6 @@
import express from "express";
import { prisma } from "../utils/db";
import { logger } from "../utils/logger";
import { prisma, Prisma } from "../utils/db";
import { requireAuth } from "../middleware/auth";
const router = express.Router();
@@ -19,7 +20,7 @@ router.get("/", requireAuth, async (req, res) => {
res.json(playbackState);
} catch (error) {
console.error("Get playback state error:", error);
logger.error("Get playback state error:", error);
res.status(500).json({ error: "Failed to get playback state" });
}
});
@@ -46,7 +47,7 @@ router.post("/", requireAuth, async (req, res) => {
// Validate playback type
const validPlaybackTypes = ["track", "audiobook", "podcast"];
if (!validPlaybackTypes.includes(playbackType)) {
console.warn(`[PlaybackState] Invalid playbackType: ${playbackType}`);
logger.warn(`[PlaybackState] Invalid playbackType: ${playbackType}`);
return res.status(400).json({ error: "Invalid playbackType" });
}
@@ -79,7 +80,7 @@ router.post("/", requireAuth, async (req, res) => {
safeQueue = null;
}
} catch (sanitizeError: any) {
console.error("[PlaybackState] Queue sanitization failed:", sanitizeError?.message);
logger.error("[PlaybackState] Queue sanitization failed:", sanitizeError?.message);
safeQueue = null; // Fall back to null queue
}
}
@@ -96,7 +97,7 @@ router.post("/", requireAuth, async (req, res) => {
trackId: trackId || null,
audiobookId: audiobookId || null,
podcastId: podcastId || null,
queue: safeQueue,
queue: safeQueue === null ? Prisma.DbNull : safeQueue,
currentIndex: safeCurrentIndex,
isShuffle: isShuffle || false,
},
@@ -106,7 +107,7 @@ router.post("/", requireAuth, async (req, res) => {
trackId: trackId || null,
audiobookId: audiobookId || null,
podcastId: podcastId || null,
queue: safeQueue,
queue: safeQueue === null ? Prisma.DbNull : safeQueue,
currentIndex: safeCurrentIndex,
isShuffle: isShuffle || false,
},
@@ -114,13 +115,13 @@ router.post("/", requireAuth, async (req, res) => {
res.json(playbackState);
} catch (error: any) {
console.error("[PlaybackState] Error saving state:", error?.message || error);
console.error("[PlaybackState] Full error:", JSON.stringify(error, Object.getOwnPropertyNames(error), 2));
logger.error("[PlaybackState] Error saving state:", error?.message || error);
logger.error("[PlaybackState] Full error:", JSON.stringify(error, Object.getOwnPropertyNames(error), 2));
if (error?.code) {
console.error("[PlaybackState] Error code:", error.code);
logger.error("[PlaybackState] Error code:", error.code);
}
if (error?.meta) {
console.error("[PlaybackState] Prisma meta:", error.meta);
logger.error("[PlaybackState] Prisma meta:", error.meta);
}
// Return more specific error for debugging
res.status(500).json({
@@ -141,7 +142,7 @@ router.delete("/", requireAuth, async (req, res) => {
res.json({ success: true });
} catch (error) {
console.error("Delete playback state error:", error);
logger.error("Delete playback state error:", error);
res.status(500).json({ error: "Failed to delete playback state" });
}
});
+60 -32
View File
@@ -1,7 +1,8 @@
import { Router } from "express";
import { logger } from "../utils/logger";
import { z } from "zod";
import { requireAuthOrToken } from "../middleware/auth";
import { prisma } from "../utils/db";
import { z } from "zod";
import { sessionLog } from "../utils/playlistLogger";
const router = Router();
@@ -20,6 +21,9 @@ const addTrackSchema = z.object({
// GET /playlists
router.get("/", async (req, res) => {
try {
if (!req.user) {
return res.status(401).json({ error: "Unauthorized" });
}
const userId = req.user.id;
// Get user's hidden playlists
@@ -74,11 +78,11 @@ router.get("/", async (req, res) => {
// Debug: log shared playlists with user info
const sharedPlaylists = playlistsWithCounts.filter((p) => !p.isOwner);
if (sharedPlaylists.length > 0) {
console.log(
logger.debug(
`[Playlists] Found ${sharedPlaylists.length} shared playlists for user ${userId}:`
);
sharedPlaylists.forEach((p) => {
console.log(
logger.debug(
` - "${p.name}" by ${
p.user?.username || "UNKNOWN"
} (owner: ${p.userId})`
@@ -88,7 +92,7 @@ router.get("/", async (req, res) => {
res.json(playlistsWithCounts);
} catch (error) {
console.error("Get playlists error:", error);
logger.error("Get playlists error:", error);
res.status(500).json({ error: "Failed to get playlists" });
}
});
@@ -96,6 +100,9 @@ router.get("/", async (req, res) => {
// POST /playlists
router.post("/", async (req, res) => {
try {
if (!req.user) {
return res.status(401).json({ error: "Unauthorized" });
}
const userId = req.user.id;
const data = createPlaylistSchema.parse(req.body);
@@ -114,7 +121,7 @@ router.post("/", async (req, res) => {
.status(400)
.json({ error: "Invalid request", details: error.errors });
}
console.error("Create playlist error:", error);
logger.error("Create playlist error:", error);
res.status(500).json({ error: "Failed to create playlist" });
}
});
@@ -122,6 +129,9 @@ router.post("/", async (req, res) => {
// GET /playlists/:id
router.get("/:id", async (req, res) => {
try {
if (!req.user) {
return res.status(401).json({ error: "Unauthorized" });
}
const userId = req.user.id;
const playlist = await prisma.playlist.findUnique({
@@ -132,6 +142,10 @@ router.get("/:id", async (req, res) => {
username: true,
},
},
hiddenByUsers: {
where: { userId },
select: { id: true },
},
items: {
include: {
track: {
@@ -203,6 +217,7 @@ router.get("/:id", async (req, res) => {
res.json({
...playlist,
isOwner: playlist.userId === userId,
isHidden: playlist.hiddenByUsers.length > 0,
trackCount: playlist.items.length,
pendingCount: playlist.pendingTracks.length,
items: formattedItems,
@@ -210,7 +225,7 @@ router.get("/:id", async (req, res) => {
mergedItems,
});
} catch (error) {
console.error("Get playlist error:", error);
logger.error("Get playlist error:", error);
res.status(500).json({ error: "Failed to get playlist" });
}
});
@@ -218,6 +233,9 @@ router.get("/:id", async (req, res) => {
// PUT /playlists/:id
router.put("/:id", async (req, res) => {
try {
if (!req.user) {
return res.status(401).json({ error: "Unauthorized" });
}
const userId = req.user.id;
const data = createPlaylistSchema.parse(req.body);
@@ -249,7 +267,7 @@ router.put("/:id", async (req, res) => {
.status(400)
.json({ error: "Invalid request", details: error.errors });
}
console.error("Update playlist error:", error);
logger.error("Update playlist error:", error);
res.status(500).json({ error: "Failed to update playlist" });
}
});
@@ -257,6 +275,9 @@ router.put("/:id", async (req, res) => {
// POST /playlists/:id/hide - Hide any playlist from your view
router.post("/:id/hide", async (req, res) => {
try {
if (!req.user) {
return res.status(401).json({ error: "Unauthorized" });
}
const userId = req.user.id;
const playlistId = req.params.id;
@@ -285,7 +306,7 @@ router.post("/:id/hide", async (req, res) => {
res.json({ message: "Playlist hidden", isHidden: true });
} catch (error) {
console.error("Hide playlist error:", error);
logger.error("Hide playlist error:", error);
res.status(500).json({ error: "Failed to hide playlist" });
}
});
@@ -293,6 +314,9 @@ router.post("/:id/hide", async (req, res) => {
// DELETE /playlists/:id/hide - Unhide a shared playlist
router.delete("/:id/hide", async (req, res) => {
try {
if (!req.user) {
return res.status(401).json({ error: "Unauthorized" });
}
const userId = req.user.id;
const playlistId = req.params.id;
@@ -303,7 +327,7 @@ router.delete("/:id/hide", async (req, res) => {
res.json({ message: "Playlist unhidden", isHidden: false });
} catch (error) {
console.error("Unhide playlist error:", error);
logger.error("Unhide playlist error:", error);
res.status(500).json({ error: "Failed to unhide playlist" });
}
});
@@ -311,6 +335,9 @@ router.delete("/:id/hide", async (req, res) => {
// DELETE /playlists/:id
router.delete("/:id", async (req, res) => {
try {
if (!req.user) {
return res.status(401).json({ error: "Unauthorized" });
}
const userId = req.user.id;
// Check ownership
@@ -332,7 +359,7 @@ router.delete("/:id", async (req, res) => {
res.json({ message: "Playlist deleted" });
} catch (error) {
console.error("Delete playlist error:", error);
logger.error("Delete playlist error:", error);
res.status(500).json({ error: "Failed to delete playlist" });
}
});
@@ -340,6 +367,7 @@ router.delete("/:id", async (req, res) => {
// POST /playlists/:id/items
router.post("/:id/items", async (req, res) => {
try {
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
const userId = req.user.id;
const parsedBody = addTrackSchema.safeParse(req.body);
if (!parsedBody.success) {
@@ -425,7 +453,7 @@ router.post("/:id/items", async (req, res) => {
.status(400)
.json({ error: "Invalid request", details: error.errors });
}
console.error("Add track to playlist error:", error);
logger.error("Add track to playlist error:", error);
res.status(500).json({ error: "Failed to add track to playlist" });
}
});
@@ -433,7 +461,7 @@ router.post("/:id/items", async (req, res) => {
// DELETE /playlists/:id/items/:trackId
router.delete("/:id/items/:trackId", async (req, res) => {
try {
const userId = req.user.id;
const userId = req.user!.id;
// Check ownership
const playlist = await prisma.playlist.findUnique({
@@ -459,7 +487,7 @@ router.delete("/:id/items/:trackId", async (req, res) => {
res.json({ message: "Track removed from playlist" });
} catch (error) {
console.error("Remove track from playlist error:", error);
logger.error("Remove track from playlist error:", error);
res.status(500).json({ error: "Failed to remove track from playlist" });
}
});
@@ -467,7 +495,7 @@ router.delete("/:id/items/:trackId", async (req, res) => {
// PUT /playlists/:id/items/reorder
router.put("/:id/items/reorder", async (req, res) => {
try {
const userId = req.user.id;
const userId = req.user!.id;
const { trackIds } = req.body; // Array of track IDs in new order
if (!Array.isArray(trackIds)) {
@@ -504,7 +532,7 @@ router.put("/:id/items/reorder", async (req, res) => {
res.json({ message: "Playlist reordered" });
} catch (error) {
console.error("Reorder playlist error:", error);
logger.error("Reorder playlist error:", error);
res.status(500).json({ error: "Failed to reorder playlist" });
}
});
@@ -519,7 +547,7 @@ router.put("/:id/items/reorder", async (req, res) => {
*/
router.get("/:id/pending", async (req, res) => {
try {
const userId = req.user.id;
const userId = req.user!.id;
const playlistId = req.params.id;
// Check ownership or public access
@@ -553,7 +581,7 @@ router.get("/:id/pending", async (req, res) => {
spotifyPlaylistId: playlist.spotifyPlaylistId,
});
} catch (error) {
console.error("Get pending tracks error:", error);
logger.error("Get pending tracks error:", error);
res.status(500).json({ error: "Failed to get pending tracks" });
}
});
@@ -564,7 +592,7 @@ router.get("/:id/pending", async (req, res) => {
*/
router.delete("/:id/pending/:trackId", async (req, res) => {
try {
const userId = req.user.id;
const userId = req.user!.id;
const { id: playlistId, trackId: pendingTrackId } = req.params;
// Check ownership
@@ -589,7 +617,7 @@ router.delete("/:id/pending/:trackId", async (req, res) => {
if (error.code === "P2025") {
return res.status(404).json({ error: "Pending track not found" });
}
console.error("Delete pending track error:", error);
logger.error("Delete pending track error:", error);
res.status(500).json({ error: "Failed to delete pending track" });
}
});
@@ -632,7 +660,7 @@ router.get("/:id/pending/:trackId/preview", async (req, res) => {
res.json({ previewUrl });
} catch (error: any) {
console.error("Get preview URL error:", error);
logger.error("Get preview URL error:", error);
res.status(500).json({ error: "Failed to get preview URL" });
}
});
@@ -644,7 +672,7 @@ router.get("/:id/pending/:trackId/preview", async (req, res) => {
*/
router.post("/:id/pending/:trackId/retry", async (req, res) => {
try {
const userId = req.user.id;
const userId = req.user!.id;
const { id: playlistId, trackId: pendingTrackId } = req.params;
sessionLog(
@@ -771,7 +799,7 @@ router.post("/:id/pending/:trackId/retry", async (req, res) => {
? pendingTrack.spotifyAlbum
: pendingTrack.spotifyArtist; // Use artist as fallback folder name
console.log(
logger.debug(
`[Retry] Starting download for: ${pendingTrack.spotifyArtist} - ${pendingTrack.spotifyTitle}`
);
sessionLog(
@@ -787,7 +815,7 @@ router.post("/:id/pending/:trackId/retry", async (req, res) => {
);
if (!searchResult.found || searchResult.allMatches.length === 0) {
console.log(`[Retry] No results found on Soulseek`);
logger.debug(`[Retry] No results found on Soulseek`);
sessionLog("PENDING-RETRY", `No results found on Soulseek`, "INFO");
await prisma.downloadJob.update({
@@ -806,7 +834,7 @@ router.post("/:id/pending/:trackId/retry", async (req, res) => {
});
}
console.log(
logger.debug(
`[Retry] ✓ Found ${searchResult.allMatches.length} results, starting download in background`
);
sessionLog(
@@ -833,7 +861,7 @@ router.post("/:id/pending/:trackId/retry", async (req, res) => {
)
.then(async (result) => {
if (result.success) {
console.log(
logger.debug(
`[Retry] ✓ Download complete: ${result.filePath}`
);
sessionLog(
@@ -870,7 +898,7 @@ router.post("/:id/pending/:trackId/retry", async (req, res) => {
removeOnComplete: true,
}
);
console.log(
logger.debug(
`[Retry] Queued library scan to reconcile pending tracks`
);
sessionLog(
@@ -880,7 +908,7 @@ router.post("/:id/pending/:trackId/retry", async (req, res) => {
})`
);
} catch (scanError) {
console.error(
logger.error(
`[Retry] Failed to queue scan:`,
scanError
);
@@ -893,7 +921,7 @@ router.post("/:id/pending/:trackId/retry", async (req, res) => {
);
}
} else {
console.log(`[Retry] Download failed: ${result.error}`);
logger.debug(`[Retry] Download failed: ${result.error}`);
sessionLog(
"PENDING-RETRY",
`Download failed: ${result.error || "unknown error"}`,
@@ -911,7 +939,7 @@ router.post("/:id/pending/:trackId/retry", async (req, res) => {
}
})
.catch((error) => {
console.error(`[Retry] Download error:`, error);
logger.error(`[Retry] Download error:`, error);
sessionLog(
"PENDING-RETRY",
`Download exception: ${error?.message || error}`,
@@ -930,7 +958,7 @@ router.post("/:id/pending/:trackId/retry", async (req, res) => {
.catch(() => undefined);
});
} catch (error: any) {
console.error("Retry pending track error:", error);
logger.error("Retry pending track error:", error);
sessionLog(
"PENDING-RETRY",
`Handler error: ${error?.message || error}`,
@@ -949,7 +977,7 @@ router.post("/:id/pending/:trackId/retry", async (req, res) => {
*/
router.post("/:id/pending/reconcile", async (req, res) => {
try {
const userId = req.user.id;
const userId = req.user!.id;
const playlistId = req.params.id;
// Check ownership
@@ -977,7 +1005,7 @@ router.post("/:id/pending/reconcile", async (req, res) => {
playlistsUpdated: result.playlistsUpdated,
});
} catch (error) {
console.error("Reconcile pending tracks error:", error);
logger.error("Reconcile pending tracks error:", error);
res.status(500).json({ error: "Failed to reconcile pending tracks" });
}
});
+3 -2
View File
@@ -1,4 +1,5 @@
import { Router } from "express";
import { logger } from "../utils/logger";
import { requireAuth } from "../middleware/auth";
import { prisma } from "../utils/db";
import { z } from "zod";
@@ -40,7 +41,7 @@ router.post("/", async (req, res) => {
.status(400)
.json({ error: "Invalid request", details: error.errors });
}
console.error("Create play error:", error);
logger.error("Create play error:", error);
res.status(500).json({ error: "Failed to log play" });
}
});
@@ -76,7 +77,7 @@ router.get("/", async (req, res) => {
res.json(plays);
} catch (error) {
console.error("Get plays error:", error);
logger.error("Get plays error:", error);
res.status(500).json({ error: "Failed to get plays" });
}
});
+102 -101
View File
@@ -1,4 +1,5 @@
import { Router } from "express";
import { logger } from "../utils/logger";
import { requireAuth, requireAuthOrToken } from "../middleware/auth";
import { prisma } from "../utils/db";
import { rssParserService } from "../services/rss-parser";
@@ -16,7 +17,7 @@ const router = Router();
router.post("/sync-covers", requireAuth, async (req, res) => {
try {
const { notificationService } = await import("../services/notificationService");
console.log(" Starting podcast cover sync...");
logger.debug(" Starting podcast cover sync...");
const podcastResult = await podcastCacheService.syncAllCovers();
const episodeResult = await podcastCacheService.syncEpisodeCovers();
@@ -25,7 +26,7 @@ router.post("/sync-covers", requireAuth, async (req, res) => {
await notificationService.notifySystem(
req.user!.id,
"Podcast Covers Synced",
`Synced ${podcastResult.cached || 0} podcast covers and ${episodeResult.cached || 0} episode covers`
`Synced ${podcastResult.synced || 0} podcast covers and ${episodeResult.synced || 0} episode covers`
);
res.json({
@@ -34,7 +35,7 @@ router.post("/sync-covers", requireAuth, async (req, res) => {
episodes: episodeResult,
});
} catch (error: any) {
console.error("Podcast cover sync failed:", error);
logger.error("Podcast cover sync failed:", error);
res.status(500).json({
error: "Sync failed",
message: error.message,
@@ -110,7 +111,7 @@ router.get("/", async (req, res) => {
res.json(podcasts);
} catch (error: any) {
console.error("Error fetching podcasts:", error);
logger.error("Error fetching podcasts:", error);
res.status(500).json({
error: "Failed to fetch podcasts",
message: error.message,
@@ -127,7 +128,7 @@ router.get("/discover/top", requireAuthOrToken, async (req, res) => {
const { limit = "20" } = req.query;
const podcastLimit = Math.min(parseInt(limit as string, 10), 50);
console.log(`\n[TOP PODCASTS] Request (limit: ${podcastLimit})`);
logger.debug(`\n[TOP PODCASTS] Request (limit: ${podcastLimit})`);
// Simple iTunes search - same as the working search bar!
const itunesResponse = await axios.get(
@@ -155,10 +156,10 @@ router.get("/discover/top", requireAuthOrToken, async (req, res) => {
isExternal: true,
}));
console.log(` Found ${podcasts.length} podcasts`);
logger.debug(` Found ${podcasts.length} podcasts`);
res.json(podcasts);
} catch (error: any) {
console.error("Error fetching top podcasts:", error);
logger.error("Error fetching top podcasts:", error);
res.status(500).json({
error: "Failed to fetch top podcasts",
message: error.message,
@@ -174,7 +175,7 @@ router.get("/discover/genres", async (req, res) => {
try {
const { genres } = req.query; // Comma-separated genre IDs
console.log(`\n[GENRE PODCASTS] Request (genres: ${genres})`);
logger.debug(`\n[GENRE PODCASTS] Request (genres: ${genres})`);
if (!genres || typeof genres !== "string") {
return res.status(400).json({
@@ -198,7 +199,7 @@ router.get("/discover/genres", async (req, res) => {
// Fetch podcasts for each genre using simple iTunes search - PARALLEL execution
const genreFetchPromises = genreIds.map(async (genreId) => {
const searchTerm = genreSearchTerms[genreId] || "podcast";
console.log(` Searching for "${searchTerm}"...`);
logger.debug(` Searching for "${searchTerm}"...`);
try {
// Simple iTunes search - same as the working search bar!
@@ -230,12 +231,12 @@ router.get("/discover/genres", async (req, res) => {
})
);
console.log(
logger.debug(
` Found ${podcasts.length} podcasts for genre ${genreId}`
);
return { genreId, podcasts };
} catch (error: any) {
console.error(
logger.error(
` Error searching for ${searchTerm}:`,
error.message
);
@@ -252,12 +253,12 @@ router.get("/discover/genres", async (req, res) => {
results[genreId] = podcasts;
}
console.log(
logger.debug(
` Fetched podcasts for ${genreIds.length} genres (parallel)`
);
res.json(results);
} catch (error: any) {
console.error("Error fetching genre podcasts:", error);
logger.error("Error fetching genre podcasts:", error);
res.status(500).json({
error: "Failed to fetch genre podcasts",
message: error.message,
@@ -277,7 +278,7 @@ router.get("/discover/genre/:genreId", async (req, res) => {
const podcastLimit = Math.min(parseInt(limit as string, 10), 50);
const podcastOffset = parseInt(offset as string, 10);
console.log(
logger.debug(
`\n[GENRE PAGINATED] Request (genre: ${genreId}, limit: ${podcastLimit}, offset: ${podcastOffset})`
);
@@ -293,7 +294,7 @@ router.get("/discover/genre/:genreId", async (req, res) => {
};
const searchTerm = genreSearchTerms[genreId] || "podcast";
console.log(
logger.debug(
` Searching for "${searchTerm}" (offset: ${podcastOffset})...`
);
@@ -332,12 +333,12 @@ router.get("/discover/genre/:genreId", async (req, res) => {
podcastOffset + podcastLimit
);
console.log(
logger.debug(
` Found ${podcasts.length} podcasts (total available: ${allPodcasts.length})`
);
res.json(podcasts);
} catch (error: any) {
console.error("Error fetching paginated genre podcasts:", error);
logger.error("Error fetching paginated genre podcasts:", error);
res.status(500).json({
error: "Failed to fetch podcasts",
message: error.message,
@@ -354,7 +355,7 @@ router.get("/preview/:itunesId", async (req, res) => {
try {
const { itunesId } = req.params;
console.log(`\n [PODCAST PREVIEW] iTunes ID: ${itunesId}`);
logger.debug(`\n [PODCAST PREVIEW] iTunes ID: ${itunesId}`);
// Try to fetch from iTunes API
const itunesResponse = await axios.get(
@@ -406,7 +407,7 @@ router.get("/preview/:itunesId", async (req, res) => {
podcastData.feedUrl
);
description =
feedData.description || feedData.itunes?.summary || "";
feedData.podcast.description || "";
// Get first 3 episodes for preview
previewEpisodes = (feedData.episodes || [])
@@ -417,11 +418,11 @@ router.get("/preview/:itunesId", async (req, res) => {
duration: episode.duration || 0,
}));
console.log(
logger.debug(
` [PODCAST PREVIEW] Fetched description (${description.length} chars) and ${previewEpisodes.length} preview episodes`
);
} catch (error) {
console.warn(` Failed to fetch RSS feed for preview:`, error);
logger.warn(` Failed to fetch RSS feed for preview:`, error);
// Continue without description and episodes
}
}
@@ -440,7 +441,7 @@ router.get("/preview/:itunesId", async (req, res) => {
subscribedPodcastId: isSubscribed ? existingPodcast!.id : null,
});
} catch (error: any) {
console.error("Error previewing podcast:", error);
logger.error("Error previewing podcast:", error);
res.status(500).json({
error: "Failed to preview podcast",
message: error.message,
@@ -532,7 +533,7 @@ router.get("/:id", async (req, res) => {
isSubscribed: true,
});
} catch (error: any) {
console.error("Error fetching podcast:", error);
logger.error("Error fetching podcast:", error);
res.status(500).json({
error: "Failed to fetch podcast",
message: error.message,
@@ -554,17 +555,17 @@ router.post("/subscribe", async (req, res) => {
.json({ error: "feedUrl or itunesId is required" });
}
console.log(
logger.debug(
`\n [PODCAST] Subscribe request from ${req.user!.username}`
);
console.log(` Feed URL: ${feedUrl || "N/A"}`);
console.log(` iTunes ID: ${itunesId || "N/A"}`);
logger.debug(` Feed URL: ${feedUrl || "N/A"}`);
logger.debug(` iTunes ID: ${itunesId || "N/A"}`);
let finalFeedUrl = feedUrl;
// If only iTunes ID provided, fetch feed URL from iTunes API
if (!finalFeedUrl && itunesId) {
console.log(` Looking up feed URL from iTunes...`);
logger.debug(` Looking up feed URL from iTunes...`);
const itunesResponse = await axios.get(
"https://itunes.apple.com/lookup",
{
@@ -582,7 +583,7 @@ router.post("/subscribe", async (req, res) => {
}
finalFeedUrl = itunesResponse.data.results[0].feedUrl;
console.log(` Found feed URL: ${finalFeedUrl}`);
logger.debug(` Found feed URL: ${finalFeedUrl}`);
}
// Check if podcast already exists in database
@@ -591,7 +592,7 @@ router.post("/subscribe", async (req, res) => {
});
if (podcast) {
console.log(` Podcast exists in database: ${podcast.title}`);
logger.debug(` Podcast exists in database: ${podcast.title}`);
// Check if user is already subscribed
const existingSubscription =
@@ -605,7 +606,7 @@ router.post("/subscribe", async (req, res) => {
});
if (existingSubscription) {
console.log(` User already subscribed`);
logger.debug(` User already subscribed`);
return res.json({
success: true,
podcast: {
@@ -624,7 +625,7 @@ router.post("/subscribe", async (req, res) => {
},
});
console.log(` User subscribed to existing podcast`);
logger.debug(` User subscribed to existing podcast`);
return res.json({
success: true,
podcast: {
@@ -636,14 +637,14 @@ router.post("/subscribe", async (req, res) => {
}
// Parse RSS feed to get podcast and episodes
console.log(` Parsing RSS feed...`);
logger.debug(` Parsing RSS feed...`);
const { podcast: podcastData, episodes } =
await rssParserService.parseFeed(finalFeedUrl);
// Create podcast in database
console.log(` Saving podcast to database...`);
logger.debug(` Saving podcast to database...`);
const finalItunesId = itunesId || podcastData.itunesId;
console.log(` iTunes ID to save: ${finalItunesId || "NONE"}`);
logger.debug(` iTunes ID to save: ${finalItunesId || "NONE"}`);
podcast = await prisma.podcast.create({
data: {
@@ -659,11 +660,11 @@ router.post("/subscribe", async (req, res) => {
},
});
console.log(` Podcast created: ${podcast.id}`);
console.log(` iTunes ID saved: ${podcast.itunesId || "NONE"}`);
logger.debug(` Podcast created: ${podcast.id}`);
logger.debug(` iTunes ID saved: ${podcast.itunesId || "NONE"}`);
// Save episodes
console.log(` Saving ${episodes.length} episodes...`);
logger.debug(` Saving ${episodes.length} episodes...`);
await prisma.podcastEpisode.createMany({
data: episodes.map((ep) => ({
podcastId: podcast!.id,
@@ -682,7 +683,7 @@ router.post("/subscribe", async (req, res) => {
skipDuplicates: true,
});
console.log(` Episodes saved`);
logger.debug(` Episodes saved`);
// Subscribe user
await prisma.podcastSubscription.create({
@@ -692,7 +693,7 @@ router.post("/subscribe", async (req, res) => {
},
});
console.log(` User subscribed successfully`);
logger.debug(` User subscribed successfully`);
res.json({
success: true,
@@ -703,7 +704,7 @@ router.post("/subscribe", async (req, res) => {
message: "Subscribed successfully",
});
} catch (error: any) {
console.error("Error subscribing to podcast:", error);
logger.error("Error subscribing to podcast:", error);
res.status(500).json({
error: "Failed to subscribe to podcast",
message: error.message,
@@ -719,9 +720,9 @@ router.delete("/:id/unsubscribe", async (req, res) => {
try {
const { id } = req.params;
console.log(`\n[PODCAST] Unsubscribe request`);
console.log(` User: ${req.user!.username}`);
console.log(` Podcast ID: ${id}`);
logger.debug(`\n[PODCAST] Unsubscribe request`);
logger.debug(` User: ${req.user!.username}`);
logger.debug(` Podcast ID: ${id}`);
// Delete subscription
const deleted = await prisma.podcastSubscription.deleteMany({
@@ -757,14 +758,14 @@ router.delete("/:id/unsubscribe", async (req, res) => {
},
});
console.log(` Unsubscribed successfully`);
logger.debug(` Unsubscribed successfully`);
res.json({
success: true,
message: "Unsubscribed successfully",
});
} catch (error: any) {
console.error("Error unsubscribing from podcast:", error);
logger.error("Error unsubscribing from podcast:", error);
res.status(500).json({
error: "Failed to unsubscribe",
message: error.message,
@@ -780,8 +781,8 @@ router.get("/:id/refresh", async (req, res) => {
try {
const { id } = req.params;
console.log(`\n [PODCAST] Refresh request`);
console.log(` Podcast ID: ${id}`);
logger.debug(`\n [PODCAST] Refresh request`);
logger.debug(` Podcast ID: ${id}`);
const podcast = await prisma.podcast.findUnique({
where: { id },
@@ -792,7 +793,7 @@ router.get("/:id/refresh", async (req, res) => {
}
// Parse RSS feed
console.log(` Parsing RSS feed...`);
logger.debug(` Parsing RSS feed...`);
const { podcast: podcastData, episodes } =
await rssParserService.parseFeed(podcast.feedUrl);
@@ -844,7 +845,7 @@ router.get("/:id/refresh", async (req, res) => {
}
}
console.log(
logger.debug(
` Refresh complete. ${newEpisodesCount} new episodes added.`
);
@@ -855,7 +856,7 @@ router.get("/:id/refresh", async (req, res) => {
message: `Found ${newEpisodesCount} new episodes`,
});
} catch (error: any) {
console.error("Error refreshing podcast:", error);
logger.error("Error refreshing podcast:", error);
res.status(500).json({
error: "Failed to refresh podcast",
message: error.message,
@@ -888,7 +889,7 @@ router.get("/:podcastId/episodes/:episodeId/cache-status", async (req, res) => {
path: cachedPath ? true : false, // Don't expose actual path
});
} catch (error: any) {
console.error("[PODCAST] Cache status check failed:", error);
logger.error("[PODCAST] Cache status check failed:", error);
res.status(500).json({ error: "Failed to check cache status" });
}
});
@@ -904,12 +905,12 @@ router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
const userId = req.user?.id;
const podcastDebug = process.env.PODCAST_DEBUG === "1";
console.log(`\n [PODCAST STREAM] Request:`);
console.log(` Podcast ID: ${podcastId}`);
console.log(` Episode ID: ${episodeId}`);
logger.debug(`\n [PODCAST STREAM] Request:`);
logger.debug(` Podcast ID: ${podcastId}`);
logger.debug(` Episode ID: ${episodeId}`);
if (podcastDebug) {
console.log(` Range: ${req.headers.range || "none"}`);
console.log(` UA: ${req.headers["user-agent"] || "unknown"}`);
logger.debug(` Range: ${req.headers.range || "none"}`);
logger.debug(` UA: ${req.headers["user-agent"] || "unknown"}`);
}
const episode = await prisma.podcastEpisode.findUnique({
@@ -921,10 +922,10 @@ router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
}
if (podcastDebug) {
console.log(` Episode DB: title="${episode.title}"`);
console.log(` Episode DB: guid="${episode.guid}"`);
console.log(` Episode DB: audioUrl="${episode.audioUrl}"`);
console.log(` Episode DB: mimeType="${episode.mimeType || "unknown"}" fileSize=${episode.fileSize || 0}`);
logger.debug(` Episode DB: title="${episode.title}"`);
logger.debug(` Episode DB: guid="${episode.guid}"`);
logger.debug(` Episode DB: audioUrl="${episode.audioUrl}"`);
logger.debug(` Episode DB: mimeType="${episode.mimeType || "unknown"}" fileSize=${episode.fileSize || 0}`);
}
const range = req.headers.range;
@@ -937,12 +938,12 @@ router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
const cachedPath = await getCachedFilePath(episodeId);
if (cachedPath) {
console.log(` Streaming from cache: ${cachedPath}`);
logger.debug(` Streaming from cache: ${cachedPath}`);
try {
const stats = await fs.promises.stat(cachedPath);
const fileSize = stats.size;
if (podcastDebug) {
console.log(` Cache file size: ${fileSize}`);
logger.debug(` Cache file size: ${fileSize}`);
}
if (fileSize === 0) {
@@ -958,7 +959,7 @@ router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
// Validate range bounds
if (start >= fileSize) {
console.log(
logger.debug(
` Range start ${start} >= file size ${fileSize}, clamping to EOF`
);
// Browsers can occasionally request a range start beyond EOF during media seeking.
@@ -987,7 +988,7 @@ router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
});
fileStream.pipe(res);
fileStream.on("error", (err) => {
console.error(" Cache stream error:", err);
logger.error(" Cache stream error:", err);
if (!res.headersSent) {
res.status(500).json({
error: "Failed to stream episode",
@@ -1002,7 +1003,7 @@ router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
const validEnd = Math.min(end, fileSize - 1);
const chunkSize = validEnd - start + 1;
console.log(
logger.debug(
` Serving range: bytes ${start}-${validEnd}/${fileSize}`
);
@@ -1029,7 +1030,7 @@ router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
});
fileStream.pipe(res);
fileStream.on("error", (err) => {
console.error(" Cache stream error:", err);
logger.error(" Cache stream error:", err);
if (!res.headersSent) {
res.status(500).json({
error: "Failed to stream episode",
@@ -1042,7 +1043,7 @@ router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
}
// No range - serve entire file
console.log(` Serving full file: ${fileSize} bytes`);
logger.debug(` Serving full file: ${fileSize} bytes`);
res.writeHead(200, {
"Content-Type": episode.mimeType || "audio/mpeg",
"Content-Length": fileSize,
@@ -1061,7 +1062,7 @@ router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
});
fileStream.pipe(res);
fileStream.on("error", (err) => {
console.error(" Cache stream error:", err);
logger.error(" Cache stream error:", err);
if (!res.headersSent) {
res.status(500).json({
error: "Failed to stream episode",
@@ -1072,7 +1073,7 @@ router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
});
return; // CRITICAL: Exit after starting cache stream
} catch (err: any) {
console.error(
logger.error(
" Failed to stream from cache, falling back to RSS:",
err.message
);
@@ -1082,12 +1083,12 @@ router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
// Not cached yet - trigger background download while streaming from RSS
if (userId && !isDownloading(episodeId)) {
console.log(` Triggering background download for caching`);
logger.debug(` Triggering background download for caching`);
downloadInBackground(episodeId, episode.audioUrl, userId);
}
// Stream from RSS URL
console.log(` Streaming from RSS: ${episode.audioUrl}`);
logger.debug(` Streaming from RSS: ${episode.audioUrl}`);
// Get file size first for proper range handling
let fileSize = episode.fileSize;
@@ -1104,7 +1105,7 @@ router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
});
}
} catch (err) {
console.warn(" Could not get file size via HEAD request");
logger.warn(" Could not get file size via HEAD request");
}
}
@@ -1115,7 +1116,7 @@ router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunkSize = end - start + 1;
console.log(` Range request: bytes=${start}-${end}/${fileSize}`);
logger.debug(` Range request: bytes=${start}-${end}/${fileSize}`);
try {
// Try range request first
@@ -1149,7 +1150,7 @@ router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
} catch (rangeError: any) {
// 416 = Range Not Satisfiable - many podcast CDNs don't support range requests
// Fall back to streaming the full file and let the browser handle seeking
console.log(
logger.debug(
` Range request failed (${
rangeError.response?.status || rangeError.message
}), falling back to full stream`
@@ -1183,7 +1184,7 @@ router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
}
} else {
// No range request - stream entire file
console.log(` Streaming full file`);
logger.debug(` Streaming full file`);
const response = await axios.get(episode.audioUrl, {
responseType: "stream",
@@ -1209,7 +1210,7 @@ router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
response.data.pipe(res);
}
} catch (error: any) {
console.error("\n [PODCAST STREAM] Error:", error.message);
logger.error("\n [PODCAST STREAM] Error:", error.message);
if (!res.headersSent) {
res.status(500).json({
error: "Failed to stream episode",
@@ -1228,12 +1229,12 @@ router.post("/:podcastId/episodes/:episodeId/progress", async (req, res) => {
const { podcastId, episodeId } = req.params;
const { currentTime, duration, isFinished } = req.body;
console.log(`\n [PODCAST PROGRESS] Update:`);
console.log(` User: ${req.user!.username}`);
console.log(` Episode ID: ${episodeId}`);
console.log(` Current Time: ${currentTime}s`);
console.log(` Duration: ${duration}s`);
console.log(` Finished: ${isFinished}`);
logger.debug(`\n [PODCAST PROGRESS] Update:`);
logger.debug(` User: ${req.user!.username}`);
logger.debug(` Episode ID: ${episodeId}`);
logger.debug(` Current Time: ${currentTime}s`);
logger.debug(` Duration: ${duration}s`);
logger.debug(` Finished: ${isFinished}`);
const progress = await prisma.podcastProgress.upsert({
where: {
@@ -1257,7 +1258,7 @@ router.post("/:podcastId/episodes/:episodeId/progress", async (req, res) => {
},
});
console.log(` Progress saved`);
logger.debug(` Progress saved`);
res.json({
success: true,
@@ -1271,7 +1272,7 @@ router.post("/:podcastId/episodes/:episodeId/progress", async (req, res) => {
},
});
} catch (error: any) {
console.error("Error updating progress:", error);
logger.error("Error updating progress:", error);
res.status(500).json({
error: "Failed to update progress",
message: error.message,
@@ -1287,9 +1288,9 @@ router.delete("/:podcastId/episodes/:episodeId/progress", async (req, res) => {
try {
const { episodeId } = req.params;
console.log(`\n[PODCAST PROGRESS] Delete:`);
console.log(` User: ${req.user!.username}`);
console.log(` Episode ID: ${episodeId}`);
logger.debug(`\n[PODCAST PROGRESS] Delete:`);
logger.debug(` User: ${req.user!.username}`);
logger.debug(` Episode ID: ${episodeId}`);
await prisma.podcastProgress.deleteMany({
where: {
@@ -1298,14 +1299,14 @@ router.delete("/:podcastId/episodes/:episodeId/progress", async (req, res) => {
},
});
console.log(` Progress removed`);
logger.debug(` Progress removed`);
res.json({
success: true,
message: "Progress removed",
});
} catch (error: any) {
console.error("Error removing progress:", error);
logger.error("Error removing progress:", error);
res.status(500).json({
error: "Failed to remove progress",
message: error.message,
@@ -1329,7 +1330,7 @@ router.get("/:id/similar", async (req, res) => {
return res.status(404).json({ error: "Podcast not found" });
}
console.log(`\n [SIMILAR PODCASTS] Request for: ${podcast.title}`);
logger.debug(`\n [SIMILAR PODCASTS] Request for: ${podcast.title}`);
try {
// Check cache first
@@ -1344,7 +1345,7 @@ router.get("/:id/similar", async (req, res) => {
});
if (cachedRecommendations.length > 0) {
console.log(
logger.debug(
` Using ${cachedRecommendations.length} cached recommendations`
);
return res.json(
@@ -1364,15 +1365,15 @@ router.get("/:id/similar", async (req, res) => {
}
// Fetch from iTunes Search API
console.log(` Fetching from iTunes Search API...`);
logger.debug(` Fetching from iTunes Search API...`);
const { itunesService } = await import("../services/itunes");
const recommendations = await itunesService.getSimilarPodcasts(
podcast.title,
podcast.description || undefined,
podcast.author
podcast.description ?? undefined,
podcast.author ?? undefined
);
console.log(` Found ${recommendations.length} similar podcasts`);
logger.debug(` Found ${recommendations.length} similar podcasts`);
if (recommendations.length > 0) {
// Cache recommendations
@@ -1400,7 +1401,7 @@ router.get("/:id/similar", async (req, res) => {
})),
});
console.log(
logger.debug(
` Cached ${recommendations.length} recommendations`
);
@@ -1420,14 +1421,14 @@ router.get("/:id/similar", async (req, res) => {
);
}
} catch (error: any) {
console.warn(" iTunes search failed:", error.message);
logger.warn(" iTunes search failed:", error.message);
}
// No recommendations available
console.log(` No recommendations found`);
logger.debug(` No recommendations found`);
res.json([]);
} catch (error: any) {
console.error("Error fetching similar podcasts:", error);
logger.error("Error fetching similar podcasts:", error);
res.status(500).json({
error: "Failed to fetch similar podcasts",
message: error.message,
@@ -1488,7 +1489,7 @@ router.get("/:id/cover", async (req, res) => {
res.status(404).json({ error: "Cover not found" });
} catch (error: any) {
console.error("Error serving podcast cover:", error);
logger.error("Error serving podcast cover:", error);
res.status(500).json({
error: "Failed to serve cover",
message: error.message,
@@ -1549,7 +1550,7 @@ router.get("/episodes/:episodeId/cover", async (req, res) => {
res.status(404).json({ error: "Cover not found" });
} catch (error: any) {
console.error("Error serving episode cover:", error);
logger.error("Error serving episode cover:", error);
res.status(500).json({
error: "Failed to serve cover",
message: error.message,
+8 -7
View File
@@ -1,4 +1,5 @@
import { Router } from "express";
import { logger } from "../utils/logger";
import { requireAuth, requireAuthOrToken } from "../middleware/auth";
import { prisma } from "../utils/db";
import { lastFmService } from "../services/lastfm";
@@ -93,7 +94,7 @@ router.get("/for-you", async (req, res) => {
});
const ownedArtistIds = new Set(ownedArtists.map((a) => a.artistId));
console.log(
logger.debug(
`Filtering recommendations: ${ownedArtistIds.size} owned artists to exclude`
);
@@ -158,11 +159,11 @@ router.get("/for-you", async (req, res) => {
};
});
console.log(
logger.debug(
`Recommendations: Found ${artistsWithMetadata.length} new artists`
);
artistsWithMetadata.forEach((a) => {
console.log(
logger.debug(
` ${a.name}: coverArt=${a.coverArt ? "YES" : "NO"}, albums=${
a.albumCount
}`
@@ -171,7 +172,7 @@ router.get("/for-you", async (req, res) => {
res.json({ artists: artistsWithMetadata });
} catch (error) {
console.error("Get recommendations for you error:", error);
logger.error("Get recommendations for you error:", error);
res.status(500).json({ error: "Failed to get recommendations" });
}
});
@@ -244,7 +245,7 @@ router.get("/", async (req, res) => {
recommendations,
});
} catch (error) {
console.error("Get recommendations error:", error);
logger.error("Get recommendations error:", error);
res.status(500).json({ error: "Failed to get recommendations" });
}
});
@@ -363,7 +364,7 @@ router.get("/albums", async (req, res) => {
recommendations,
});
} catch (error) {
console.error("Get album recommendations error:", error);
logger.error("Get album recommendations error:", error);
res.status(500).json({
error: "Failed to get album recommendations",
});
@@ -459,7 +460,7 @@ router.get("/tracks", async (req, res) => {
recommendations,
});
} catch (error) {
console.error("Get track recommendations error:", error);
logger.error("Get track recommendations error:", error);
res.status(500).json({
error: "Failed to get track recommendations",
});
+16 -24
View File
@@ -1,6 +1,8 @@
import { logger } from "../utils/logger";
/**
* Release Radar API
*
*
* Provides upcoming and recent releases from:
* 1. Lidarr monitored artists (via calendar API)
* 2. Similar artists from user's library (Last.fm similar artists)
@@ -52,7 +54,7 @@ router.get("/radar", async (req, res) => {
const endDate = new Date(now);
endDate.setDate(endDate.getDate() + daysAhead);
console.log(`[Releases] Fetching radar: ${daysBack} days back, ${daysAhead} days ahead`);
logger.debug(`[Releases] Fetching radar: ${daysBack} days back, ${daysAhead} days ahead`);
// 1. Get releases from Lidarr calendar (monitored artists)
const lidarrReleases = await lidarrService.getCalendar(startDate, endDate);
@@ -92,8 +94,8 @@ router.get("/radar", async (req, res) => {
sa => sa.toArtist.mbid && !monitoredMbids.has(sa.toArtist.mbid)
);
console.log(`[Releases] Found ${lidarrReleases.length} Lidarr releases`);
console.log(`[Releases] Found ${unmonitoredSimilar.length} unmonitored similar artists`);
logger.debug(`[Releases] Found ${lidarrReleases.length} Lidarr releases`);
logger.debug(`[Releases] Found ${unmonitoredSimilar.length} unmonitored similar artists`);
// 4. Get albums in library to check what user already has
const libraryAlbums = await prisma.album.findMany({
@@ -142,7 +144,7 @@ router.get("/radar", async (req, res) => {
res.json(response);
} catch (error: any) {
console.error("[Releases] Radar error:", error.message);
logger.error("[Releases] Radar error:", error.message);
res.status(500).json({ error: "Failed to fetch release radar" });
}
});
@@ -173,7 +175,7 @@ router.get("/upcoming", async (req, res) => {
daysAhead,
});
} catch (error: any) {
console.error("[Releases] Upcoming error:", error.message);
logger.error("[Releases] Upcoming error:", error.message);
res.status(500).json({ error: "Failed to fetch upcoming releases" });
}
});
@@ -195,7 +197,6 @@ router.get("/recent", async (req, res) => {
// Get library albums to mark what's already downloaded
const libraryAlbums = await prisma.album.findMany({
where: { rgMbid: { not: null } },
select: { rgMbid: true }
});
const libraryMbids = new Set(libraryAlbums.map(a => a.rgMbid).filter(Boolean));
@@ -214,7 +215,7 @@ router.get("/recent", async (req, res) => {
inLibraryCount: releases.length - notInLibrary.length,
});
} catch (error: any) {
console.error("[Releases] Recent error:", error.message);
logger.error("[Releases] Recent error:", error.message);
res.status(500).json({ error: "Failed to fetch recent releases" });
}
});
@@ -233,24 +234,15 @@ router.post("/download/:albumMbid", async (req, res) => {
return res.status(401).json({ error: "Authentication required" });
}
console.log(`[Releases] Download requested for album: ${albumMbid}`);
logger.debug(`[Releases] Download requested for album: ${albumMbid}`);
// Use Lidarr to download the album
const result = await lidarrService.downloadAlbum(albumMbid);
if (result) {
res.json({
success: true,
message: "Download started",
albumId: result.id
});
} else {
res.status(404).json({
error: "Album not found in Lidarr or download failed"
});
}
// TODO: Implement downloadAlbum method on LidarrService
// For now, return not implemented error
res.status(501).json({
error: "Download feature not yet implemented for release radar"
});
} catch (error: any) {
console.error("[Releases] Download error:", error.message);
logger.error("[Releases] Download error:", error.message);
res.status(500).json({ error: "Failed to start download" });
}
});
+78 -38
View File
@@ -1,4 +1,5 @@
import { Router } from "express";
import { logger } from "../utils/logger";
import { requireAuth } from "../middleware/auth";
import { prisma } from "../utils/db";
import { audiobookshelfService } from "../services/audiobookshelf";
@@ -33,7 +34,7 @@ router.use(requireAuth);
* name: type
* schema:
* type: string
* enum: [all, artists, albums, tracks, audiobooks, podcasts]
* enum: [all, artists, albums, tracks, audiobooks, podcasts, episodes]
* description: Type of content to search
* default: all
* - in: query
@@ -102,11 +103,13 @@ router.get("/", async (req, res) => {
}
// Check cache for library search (short TTL since library can change)
const cacheKey = `search:library:${type}:${genre || ""}:${query}:${searchLimit}`;
const cacheKey = `search:library:${type}:${
genre || ""
}:${query}:${searchLimit}`;
try {
const cached = await redisClient.get(cacheKey);
if (cached) {
console.log(`[SEARCH] Cache hit for query="${query}"`);
logger.debug(`[SEARCH] Cache hit for query="${query}"`);
return res.json(JSON.parse(cached));
}
} catch (err) {
@@ -119,6 +122,7 @@ router.get("/", async (req, res) => {
tracks: [],
audiobooks: [],
podcasts: [],
episodes: [],
};
// Search artists using full-text search (only show artists with actual albums in library)
@@ -246,41 +250,48 @@ router.get("/", async (req, res) => {
}
}
// Search audiobooks
// Search audiobooks using FTS
if (type === "all" || type === "audiobooks") {
try {
const audiobooks = await audiobookshelfService.searchAudiobooks(
query
);
results.audiobooks = audiobooks.slice(0, searchLimit);
const audiobooks = await searchService.searchAudiobooksFTS({
query,
limit: searchLimit,
});
results.audiobooks = audiobooks;
} catch (error) {
console.error("Audiobook search error:", error);
logger.error("Audiobook search error:", error);
results.audiobooks = [];
}
}
// Search podcasts (search through owned podcasts)
// Search podcasts using FTS
if (type === "all" || type === "podcasts") {
try {
const allPodcasts =
await audiobookshelfService.getAllPodcasts();
results.podcasts = allPodcasts
.filter(
(p) =>
p.media?.metadata?.title
?.toLowerCase()
.includes(query.toLowerCase()) ||
p.media?.metadata?.author
?.toLowerCase()
.includes(query.toLowerCase())
)
.slice(0, searchLimit);
const podcasts = await searchService.searchPodcastsFTS({
query,
limit: searchLimit,
});
results.podcasts = podcasts;
} catch (error) {
console.error("Podcast search error:", error);
logger.error("Podcast search error:", error);
results.podcasts = [];
}
}
// Search podcast episodes
if (type === "all" || type === "episodes") {
try {
const episodes = await searchService.searchEpisodes({
query,
limit: searchLimit,
});
results.episodes = episodes;
} catch (error) {
logger.error("Episode search error:", error);
results.episodes = [];
}
}
// Cache search results for 2 minutes (library can change)
try {
await redisClient.setEx(cacheKey, 120, JSON.stringify(results));
@@ -290,7 +301,7 @@ router.get("/", async (req, res) => {
res.json(results);
} catch (error) {
console.error("Search error:", error);
logger.error("Search error:", error);
res.status(500).json({ error: "Search failed" });
}
});
@@ -315,7 +326,7 @@ router.get("/genres", async (req, res) => {
}))
);
} catch (error) {
console.error("Get genres error:", error);
logger.error("Get genres error:", error);
res.status(500).json({ error: "Failed to get genres" });
}
});
@@ -339,13 +350,13 @@ router.get("/discover", async (req, res) => {
try {
const cached = await redisClient.get(cacheKey);
if (cached) {
console.log(
logger.debug(
`[SEARCH DISCOVER] Cache hit for query="${query}" type=${type}`
);
return res.json(JSON.parse(cached));
}
} catch (err) {
console.warn("[SEARCH DISCOVER] Redis read error:", err);
logger.warn("[SEARCH DISCOVER] Redis read error:", err);
}
const results: any[] = [];
@@ -353,27 +364,56 @@ router.get("/discover", async (req, res) => {
if (type === "music" || type === "all") {
// Search Last.fm for artists AND tracks
try {
// Search for artists
// Check if query is a potential alias
let searchQuery = query;
let aliasInfo: any = null;
try {
const correction = await lastFmService.getArtistCorrection(query);
if (correction?.corrected) {
// Query is an alias - search for canonical name instead
searchQuery = correction.canonicalName;
aliasInfo = {
type: "alias_resolution",
original: query,
canonical: correction.canonicalName,
mbid: correction.mbid,
};
logger.debug(
`[SEARCH DISCOVER] Alias resolved: "${query}" → "${correction.canonicalName}"`
);
}
} catch (correctionError) {
logger.warn("[SEARCH DISCOVER] Correction check failed:", correctionError);
}
// Search for artists (using potentially corrected query)
const lastfmArtistResults = await lastFmService.searchArtists(
query,
searchQuery,
searchLimit
);
console.log(
logger.debug(
`[SEARCH ENDPOINT] Found ${lastfmArtistResults.length} artist results`
);
// Add alias info to response if applicable
if (aliasInfo) {
results.push(aliasInfo);
}
results.push(...lastfmArtistResults);
// Search for tracks (songs)
// Search for tracks (songs) - use corrected query for consistency
const lastfmTrackResults = await lastFmService.searchTracks(
query,
searchQuery,
searchLimit
);
console.log(
logger.debug(
`[SEARCH ENDPOINT] Found ${lastfmTrackResults.length} track results`
);
results.push(...lastfmTrackResults);
} catch (error) {
console.error("Last.fm search error:", error);
logger.error("Last.fm search error:", error);
}
}
@@ -410,7 +450,7 @@ router.get("/discover", async (req, res) => {
results.push(...podcasts);
} catch (error) {
console.error("iTunes podcast search error:", error);
logger.error("iTunes podcast search error:", error);
}
}
@@ -419,12 +459,12 @@ router.get("/discover", async (req, res) => {
try {
await redisClient.setEx(cacheKey, 900, JSON.stringify(payload));
} catch (err) {
console.warn("[SEARCH DISCOVER] Redis write error:", err);
logger.warn("[SEARCH DISCOVER] Redis write error:", err);
}
res.json(payload);
} catch (error) {
console.error("Discovery search error:", error);
logger.error("Discovery search error:", error);
res.status(500).json({ error: "Discovery search failed" });
}
});
+25 -2
View File
@@ -1,7 +1,9 @@
import { Router } from "express";
import { logger } from "../utils/logger";
import { requireAuth } from "../middleware/auth";
import { prisma } from "../utils/db";
import { z } from "zod";
import { staleJobCleanupService } from "../services/staleJobCleanup";
const router = Router();
@@ -38,7 +40,7 @@ router.get("/", async (req, res) => {
res.json(settings);
} catch (error) {
console.error("Get settings error:", error);
logger.error("Get settings error:", error);
res.status(500).json({ error: "Failed to get settings" });
}
});
@@ -65,9 +67,30 @@ router.post("/", async (req, res) => {
.status(400)
.json({ error: "Invalid settings", details: error.errors });
}
console.error("Update settings error:", error);
logger.error("Update settings error:", error);
res.status(500).json({ error: "Failed to update settings" });
}
});
// POST /settings/cleanup-stale-jobs
router.post("/cleanup-stale-jobs", async (req, res) => {
try {
const result = await staleJobCleanupService.cleanupAll();
res.json({
success: true,
cleaned: {
discoveryBatches: result.discoveryBatches,
downloadJobs: result.downloadJobs,
spotifyImportJobs: result.spotifyImportJobs,
bullQueues: result.bullQueues,
},
totalCleaned: result.totalCleaned,
});
} catch (error) {
logger.error("Stale job cleanup error:", error);
res.status(500).json({ error: "Failed to cleanup stale jobs" });
}
});
export default router;
+9 -7
View File
@@ -1,3 +1,5 @@
import { logger } from "../utils/logger";
/**
* Soulseek routes - Direct connection via slsk-client
* Simplified API for status and manual search/download
@@ -23,7 +25,7 @@ async function requireSoulseekConfigured(req: any, res: any, next: any) {
next();
} catch (error) {
console.error("Error checking Soulseek settings:", error);
logger.error("Error checking Soulseek settings:", error);
res.status(500).json({ error: "Failed to check settings" });
}
}
@@ -52,7 +54,7 @@ router.get("/status", requireAuth, async (req, res) => {
username: status.username,
});
} catch (error: any) {
console.error("Soulseek status error:", error.message);
logger.error("Soulseek status error:", error.message);
res.status(500).json({
error: "Failed to get Soulseek status",
details: error.message,
@@ -73,7 +75,7 @@ router.post("/connect", requireAuth, requireSoulseekConfigured, async (req, res)
message: "Connected to Soulseek network",
});
} catch (error: any) {
console.error("Soulseek connect error:", error.message);
logger.error("Soulseek connect error:", error.message);
res.status(500).json({
error: "Failed to connect to Soulseek",
details: error.message,
@@ -95,7 +97,7 @@ router.post("/search", requireAuth, requireSoulseekConfigured, async (req, res)
});
}
console.log(`[Soulseek] Searching: "${artist} - ${title}"`);
logger.debug(`[Soulseek] Searching: "${artist} - ${title}"`);
const result = await soulseekService.searchTrack(artist, title);
@@ -117,7 +119,7 @@ router.post("/search", requireAuth, requireSoulseekConfigured, async (req, res)
});
}
} catch (error: any) {
console.error("Soulseek search error:", error.message);
logger.error("Soulseek search error:", error.message);
res.status(500).json({
error: "Search failed",
details: error.message,
@@ -148,7 +150,7 @@ router.post("/download", requireAuth, requireSoulseekConfigured, async (req, res
});
}
console.log(`[Soulseek] Downloading: "${artist} - ${title}"`);
logger.debug(`[Soulseek] Downloading: "${artist} - ${title}"`);
const result = await soulseekService.searchAndDownload(
artist,
@@ -169,7 +171,7 @@ router.post("/download", requireAuth, requireSoulseekConfigured, async (req, res
});
}
} catch (error: any) {
console.error("Soulseek download error:", error.message);
logger.error("Soulseek download error:", error.message);
res.status(500).json({
error: "Download failed",
details: error.message,
+25 -14
View File
@@ -1,4 +1,5 @@
import { Router } from "express";
import { logger } from "../utils/logger";
import { requireAuthOrToken } from "../middleware/auth";
import { z } from "zod";
import { spotifyService } from "../services/spotify";
@@ -51,7 +52,7 @@ router.post("/parse", async (req, res) => {
url: `https://open.spotify.com/playlist/${parsed.id}`,
});
} catch (error: any) {
console.error("Spotify parse error:", error);
logger.error("Spotify parse error:", error);
if (error.name === "ZodError") {
return res.status(400).json({ error: "Invalid request body" });
}
@@ -67,7 +68,7 @@ router.post("/preview", async (req, res) => {
try {
const { url } = parseUrlSchema.parse(req.body);
console.log(`[Playlist Import] Generating preview for: ${url}`);
logger.debug(`[Playlist Import] Generating preview for: ${url}`);
// Detect if it's a Deezer URL
if (url.includes("deezer.com")) {
@@ -94,7 +95,7 @@ router.post("/preview", async (req, res) => {
deezerPlaylist
);
console.log(
logger.debug(
`[Playlist Import] Deezer preview generated: ${preview.summary.total} tracks, ${preview.summary.inLibrary} in library`
);
res.json(preview);
@@ -102,13 +103,13 @@ router.post("/preview", async (req, res) => {
// Handle Spotify URL
const preview = await spotifyImportService.generatePreview(url);
console.log(
logger.debug(
`[Spotify Import] Preview generated: ${preview.summary.total} tracks, ${preview.summary.inLibrary} in library`
);
res.json(preview);
}
} catch (error: any) {
console.error("Playlist preview error:", error);
logger.error("Playlist preview error:", error);
if (error.name === "ZodError") {
return res.status(400).json({ error: "Invalid request body" });
}
@@ -124,6 +125,9 @@ router.post("/preview", async (req, res) => {
*/
router.post("/import", async (req, res) => {
try {
if (!req.user) {
return res.status(401).json({ error: "Unauthorized" });
}
const { spotifyPlaylistId, url, playlistName, albumMbidsToDownload } =
importSchema.parse(req.body);
const userId = req.user.id;
@@ -155,10 +159,10 @@ router.post("/import", async (req, res) => {
preview = await spotifyImportService.generatePreview(effectiveUrl);
}
console.log(
logger.debug(
`[Spotify Import] Starting import for user ${userId}: ${playlistName}`
);
console.log(
logger.debug(
`[Spotify Import] Downloading ${albumMbidsToDownload.length} albums`
);
@@ -176,7 +180,7 @@ router.post("/import", async (req, res) => {
message: "Import started",
});
} catch (error: any) {
console.error("Spotify import error:", error);
logger.error("Spotify import error:", error);
if (error.name === "ZodError") {
return res.status(400).json({ error: "Invalid request body" });
}
@@ -192,6 +196,9 @@ router.post("/import", async (req, res) => {
*/
router.get("/import/:jobId/status", async (req, res) => {
try {
if (!req.user) {
return res.status(401).json({ error: "Unauthorized" });
}
const { jobId } = req.params;
const userId = req.user.id;
@@ -209,7 +216,7 @@ router.get("/import/:jobId/status", async (req, res) => {
res.json(job);
} catch (error: any) {
console.error("Spotify job status error:", error);
logger.error("Spotify job status error:", error);
res.status(500).json({
error: error.message || "Failed to get job status",
});
@@ -222,11 +229,14 @@ router.get("/import/:jobId/status", async (req, res) => {
*/
router.get("/imports", async (req, res) => {
try {
if (!req.user) {
return res.status(401).json({ error: "Unauthorized" });
}
const userId = req.user.id;
const jobs = await spotifyImportService.getUserJobs(userId);
res.json(jobs);
} catch (error: any) {
console.error("Spotify imports error:", error);
logger.error("Spotify imports error:", error);
res.status(500).json({
error: error.message || "Failed to get imports",
});
@@ -240,6 +250,7 @@ router.get("/imports", async (req, res) => {
router.post("/import/:jobId/refresh", async (req, res) => {
try {
const { jobId } = req.params;
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
const userId = req.user.id;
const job = await spotifyImportService.getJob(jobId);
@@ -265,7 +276,7 @@ router.post("/import/:jobId/refresh", async (req, res) => {
total: result.total,
});
} catch (error: any) {
console.error("Spotify refresh error:", error);
logger.error("Spotify refresh error:", error);
res.status(500).json({
error: error.message || "Failed to refresh tracks",
});
@@ -279,7 +290,7 @@ router.post("/import/:jobId/refresh", async (req, res) => {
router.post("/import/:jobId/cancel", async (req, res) => {
try {
const { jobId } = req.params;
const userId = req.user.id;
const userId = req.user!.id;
const job = await spotifyImportService.getJob(jobId);
if (!job) {
@@ -303,7 +314,7 @@ router.post("/import/:jobId/cancel", async (req, res) => {
tracksMatched: result.tracksMatched,
});
} catch (error: any) {
console.error("Spotify cancel error:", error);
logger.error("Spotify cancel error:", error);
res.status(500).json({
error: error.message || "Failed to cancel import",
});
@@ -324,7 +335,7 @@ router.get("/import/session-log", async (req, res) => {
content: log,
});
} catch (error: any) {
console.error("Session log error:", error);
logger.error("Session log error:", error);
res.status(500).json({
error: error.message || "Failed to read session log",
});
+140 -51
View File
@@ -1,4 +1,5 @@
import { Router } from "express";
import { logger } from "../utils/logger";
import { requireAuth, requireAdmin } from "../middleware/auth";
import { prisma } from "../utils/db";
import { z } from "zod";
@@ -17,7 +18,7 @@ function safeDecrypt(value: string | null): string | null {
try {
return decrypt(value);
} catch (error) {
console.warn("[Settings Route] Failed to decrypt field, returning null");
logger.warn("[Settings Route] Failed to decrypt field, returning null");
return null;
}
}
@@ -31,6 +32,7 @@ const systemSettingsSchema = z.object({
lidarrEnabled: z.boolean().optional(),
lidarrUrl: z.string().optional(),
lidarrApiKey: z.string().nullable().optional(),
lidarrWebhookSecret: z.string().nullable().optional(),
// AI Services
openaiEnabled: z.boolean().optional(),
@@ -41,6 +43,8 @@ const systemSettingsSchema = z.object({
fanartEnabled: z.boolean().optional(),
fanartApiKey: z.string().nullable().optional(),
lastfmApiKey: z.string().nullable().optional(),
// Media Services
audiobookshelfEnabled: z.boolean().optional(),
audiobookshelfUrl: z.string().optional(),
@@ -66,10 +70,11 @@ const systemSettingsSchema = z.object({
maxConcurrentDownloads: z.number().optional(),
downloadRetryAttempts: z.number().optional(),
transcodeCacheMaxGb: z.number().optional(),
soulseekConcurrentDownloads: z.number().min(1).max(10).optional(),
// Download Preferences
downloadSource: z.enum(["soulseek", "lidarr"]).optional(),
soulseekFallback: z.enum(["none", "lidarr"]).optional(),
primaryFailureFallback: z.enum(["none", "lidarr", "soulseek"]).optional(),
});
// GET /system-settings
@@ -107,8 +112,10 @@ router.get("/", async (req, res) => {
const decryptedSettings = {
...settings,
lidarrApiKey: safeDecrypt(settings.lidarrApiKey),
lidarrWebhookSecret: safeDecrypt(settings.lidarrWebhookSecret),
openaiApiKey: safeDecrypt(settings.openaiApiKey),
fanartApiKey: safeDecrypt(settings.fanartApiKey),
lastfmApiKey: safeDecrypt(settings.lastfmApiKey),
audiobookshelfApiKey: safeDecrypt(settings.audiobookshelfApiKey),
soulseekPassword: safeDecrypt(settings.soulseekPassword),
spotifyClientSecret: safeDecrypt(settings.spotifyClientSecret),
@@ -116,7 +123,7 @@ router.get("/", async (req, res) => {
res.json(decryptedSettings);
} catch (error) {
console.error("Get system settings error:", error);
logger.error("Get system settings error:", error);
res.status(500).json({ error: "Failed to get system settings" });
}
});
@@ -126,8 +133,8 @@ router.post("/", async (req, res) => {
try {
const data = systemSettingsSchema.parse(req.body);
console.log("[SYSTEM SETTINGS] Saving settings...");
console.log(
logger.debug("[SYSTEM SETTINGS] Saving settings...");
logger.debug(
"[SYSTEM SETTINGS] transcodeCacheMaxGb:",
data.transcodeCacheMaxGb
);
@@ -137,10 +144,14 @@ router.post("/", async (req, res) => {
if (data.lidarrApiKey)
encryptedData.lidarrApiKey = encrypt(data.lidarrApiKey);
if (data.lidarrWebhookSecret)
encryptedData.lidarrWebhookSecret = encrypt(data.lidarrWebhookSecret);
if (data.openaiApiKey)
encryptedData.openaiApiKey = encrypt(data.openaiApiKey);
if (data.fanartApiKey)
encryptedData.fanartApiKey = encrypt(data.fanartApiKey);
if (data.lastfmApiKey)
encryptedData.lastfmApiKey = encrypt(data.lastfmApiKey);
if (data.audiobookshelfApiKey)
encryptedData.audiobookshelfApiKey = encrypt(
data.audiobookshelfApiKey
@@ -161,19 +172,27 @@ router.post("/", async (req, res) => {
invalidateSystemSettingsCache();
// Refresh Last.fm API key if it was updated
try {
const { lastFmService } = await import("../services/lastfm");
await lastFmService.refreshApiKey();
} catch (err) {
logger.warn("Failed to refresh Last.fm API key:", err);
}
// If Audiobookshelf was disabled, clear all audiobook-related data
if (data.audiobookshelfEnabled === false) {
console.log(
logger.debug(
"[CLEANUP] Audiobookshelf disabled - clearing all audiobook data from database"
);
try {
const deletedProgress =
await prisma.audiobookProgress.deleteMany({});
console.log(
logger.debug(
` Deleted ${deletedProgress.count} audiobook progress entries`
);
} catch (clearError) {
console.error("Failed to clear audiobook data:", clearError);
logger.error("Failed to clear audiobook data:", clearError);
// Don't fail the request
}
}
@@ -191,28 +210,28 @@ router.post("/", async (req, res) => {
SOULSEEK_USERNAME: data.soulseekUsername || null,
SOULSEEK_PASSWORD: data.soulseekPassword || null,
});
console.log(".env file synchronized with database settings");
logger.debug(".env file synchronized with database settings");
} catch (envError) {
console.error("Failed to write .env file:", envError);
logger.error("Failed to write .env file:", envError);
// Don't fail the request if .env write fails
}
// Auto-configure Lidarr webhook if Lidarr is enabled
if (data.lidarrEnabled && data.lidarrUrl && data.lidarrApiKey) {
try {
console.log("[LIDARR] Auto-configuring webhook...");
logger.debug("[LIDARR] Auto-configuring webhook...");
const axios = (await import("axios")).default;
const lidarrUrl = data.lidarrUrl;
const apiKey = data.lidarrApiKey;
// Determine webhook URL
// Use LIDIFY_CALLBACK_URL env var if set, otherwise default to host.docker.internal:3030
// Port 3030 is the external Nginx port that Lidarr can reach
const callbackHost = process.env.LIDIFY_CALLBACK_URL || "http://host.docker.internal:3030";
// Use LIDIFY_CALLBACK_URL env var if set, otherwise default to backend:3006
// In Docker, services communicate via Docker network names (backend, lidarr, etc.)
const callbackHost = process.env.LIDIFY_CALLBACK_URL || "http://backend:3006";
const webhookUrl = `${callbackHost}/api/webhooks/lidarr`;
console.log(` Webhook URL: ${webhookUrl}`);
logger.debug(` Webhook URL: ${webhookUrl}`);
// Check if webhook already exists - find by name "Lidify" OR by URL containing "lidify" or "webhooks/lidarr"
const notificationsResponse = await axios.get(
@@ -241,10 +260,10 @@ router.post("/", async (req, res) => {
if (existingWebhook) {
const currentUrl = existingWebhook.fields?.find((f: any) => f.name === "url")?.value;
console.log(` Found existing webhook: "${existingWebhook.name}" with URL: ${currentUrl}`);
logger.debug(` Found existing webhook: "${existingWebhook.name}" with URL: ${currentUrl}`);
if (currentUrl !== webhookUrl) {
console.log(` URL needs updating from: ${currentUrl}`);
console.log(` URL will be updated to: ${webhookUrl}`);
logger.debug(` URL needs updating from: ${currentUrl}`);
logger.debug(` URL will be updated to: ${webhookUrl}`);
}
}
@@ -293,7 +312,7 @@ router.post("/", async (req, res) => {
timeout: 10000,
}
);
console.log(" Webhook updated");
logger.debug(" Webhook updated");
} else {
// Create new webhook (use forceSave to skip test)
await axios.post(
@@ -304,22 +323,22 @@ router.post("/", async (req, res) => {
timeout: 10000,
}
);
console.log(" Webhook created");
logger.debug(" Webhook created");
}
console.log("Lidarr webhook configured automatically\n");
logger.debug("Lidarr webhook configured automatically\n");
} catch (webhookError: any) {
console.error(
logger.error(
"Failed to auto-configure webhook:",
webhookError.message
);
if (webhookError.response?.data) {
console.error(
logger.error(
" Lidarr error details:",
JSON.stringify(webhookError.response.data, null, 2)
);
}
console.log(
logger.debug(
" User can configure webhook manually in Lidarr UI\n"
);
// Don't fail the request if webhook config fails
@@ -338,7 +357,7 @@ router.post("/", async (req, res) => {
.status(400)
.json({ error: "Invalid settings", details: error.errors });
}
console.error("Update system settings error:", error);
logger.error("Update system settings error:", error);
res.status(500).json({ error: "Failed to update system settings" });
}
});
@@ -348,7 +367,7 @@ router.post("/test-lidarr", async (req, res) => {
try {
const { url, apiKey } = req.body;
console.log("[Lidarr Test] Testing connection to:", url);
logger.debug("[Lidarr Test] Testing connection to:", url);
if (!url || !apiKey) {
return res
@@ -368,7 +387,7 @@ router.post("/test-lidarr", async (req, res) => {
}
);
console.log(
logger.debug(
"[Lidarr Test] Connection successful, version:",
response.data.version
);
@@ -379,8 +398,8 @@ router.post("/test-lidarr", async (req, res) => {
version: response.data.version,
});
} catch (error: any) {
console.error("[Lidarr Test] Error:", error.message);
console.error(
logger.error("[Lidarr Test] Error:", error.message);
logger.error(
"[Lidarr Test] Details:",
error.response?.data || error.code
);
@@ -433,7 +452,7 @@ router.post("/test-openai", async (req, res) => {
model: response.data.model,
});
} catch (error: any) {
console.error("OpenAI test error:", error.message);
logger.error("OpenAI test error:", error.message);
res.status(500).json({
error: "Failed to connect to OpenAI",
details: error.response?.data?.error?.message || error.message,
@@ -469,7 +488,7 @@ router.post("/test-fanart", async (req, res) => {
message: "Fanart.tv connection successful",
});
} catch (error: any) {
console.error("Fanart.tv test error:", error.message);
logger.error("Fanart.tv test error:", error.message);
if (error.response?.status === 401) {
res.status(401).json({
error: "Invalid Fanart.tv API key",
@@ -483,6 +502,59 @@ router.post("/test-fanart", async (req, res) => {
}
});
// Test Last.fm connection
router.post("/test-lastfm", async (req, res) => {
try {
const { lastfmApiKey } = req.body;
if (!lastfmApiKey) {
return res.status(400).json({ error: "API key is required" });
}
const axios = require("axios");
// Test with a known artist (The Beatles)
const testArtist = "The Beatles";
const response = await axios.get(
"http://ws.audioscrobbler.com/2.0/",
{
params: {
method: "artist.getinfo",
artist: testArtist,
api_key: lastfmApiKey,
format: "json",
},
timeout: 5000,
}
);
// If we get here and have artist data, the API key is valid
if (response.data.artist) {
res.json({
success: true,
message: "Last.fm connection successful",
});
} else {
res.status(500).json({
error: "Unexpected response from Last.fm",
});
}
} catch (error: any) {
logger.error("Last.fm test error:", error.message);
if (error.response?.status === 403 || error.response?.data?.error === 10) {
res.status(401).json({
error: "Invalid Last.fm API key",
});
} else {
res.status(500).json({
error: "Failed to connect to Last.fm",
details: error.response?.data || error.message,
});
}
}
});
// Test Audiobookshelf connection
router.post("/test-audiobookshelf", async (req, res) => {
try {
@@ -509,7 +581,7 @@ router.post("/test-audiobookshelf", async (req, res) => {
libraries: response.data.libraries?.length || 0,
});
} catch (error: any) {
console.error("Audiobookshelf test error:", error.message);
logger.error("Audiobookshelf test error:", error.message);
if (error.response?.status === 401 || error.response?.status === 403) {
res.status(401).json({
error: "Invalid Audiobookshelf API key",
@@ -534,7 +606,7 @@ router.post("/test-soulseek", async (req, res) => {
});
}
console.log(`[SOULSEEK-TEST] Testing connection as "${username}"...`);
logger.debug(`[SOULSEEK-TEST] Testing connection as "${username}"...`);
// Import soulseek service
const { soulseekService } = await import("../services/soulseek");
@@ -550,10 +622,10 @@ router.post("/test-soulseek", async (req, res) => {
{ user: username, pass: password },
(err: Error | null, client: any) => {
if (err) {
console.log(`[SOULSEEK-TEST] Connection failed: ${err.message}`);
logger.debug(`[SOULSEEK-TEST] Connection failed: ${err.message}`);
return reject(err);
}
console.log(`[SOULSEEK-TEST] Connected successfully`);
logger.debug(`[SOULSEEK-TEST] Connected successfully`);
// We don't need to keep the connection open for the test
resolve();
}
@@ -567,14 +639,14 @@ router.post("/test-soulseek", async (req, res) => {
isConnected: true,
});
} catch (connectError: any) {
console.error(`[SOULSEEK-TEST] Error: ${connectError.message}`);
logger.error(`[SOULSEEK-TEST] Error: ${connectError.message}`);
res.status(401).json({
error: "Invalid Soulseek credentials or connection failed",
details: connectError.message,
});
}
} catch (error: any) {
console.error("[SOULSEEK-TEST] Error:", error.message);
logger.error("[SOULSEEK-TEST] Error:", error.message);
res.status(500).json({
error: "Failed to test Soulseek connection",
details: error.message,
@@ -593,22 +665,39 @@ router.post("/test-spotify", async (req, res) => {
});
}
// Import spotifyService to test credentials
const { spotifyService } = await import("../services/spotify");
const result = await spotifyService.testCredentials(clientId, clientSecret);
// Test credentials by trying to get an access token
const axios = require("axios");
try {
const response = await axios.post(
"https://accounts.spotify.com/api/token",
"grant_type=client_credentials",
{
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`,
},
timeout: 10000,
}
);
if (result.success) {
res.json({
success: true,
message: "Spotify credentials are valid",
});
} else {
if (response.data.access_token) {
res.json({
success: true,
message: "Spotify credentials are valid",
});
} else {
res.status(401).json({
error: "Invalid Spotify credentials",
});
}
} catch (tokenError: any) {
res.status(401).json({
error: result.error || "Invalid Spotify credentials",
error: "Invalid Spotify credentials",
details: tokenError.response?.data?.error_description || tokenError.message,
});
}
} catch (error: any) {
console.error("Spotify test error:", error.message);
logger.error("Spotify test error:", error.message);
res.status(500).json({
error: "Failed to test Spotify credentials",
details: error.message,
@@ -661,7 +750,7 @@ router.post("/clear-caches", async (req, res) => {
);
if (keysToDelete.length > 0) {
console.log(
logger.debug(
`[CACHE] Clearing ${
keysToDelete.length
} cache entries (excluding ${
@@ -671,7 +760,7 @@ router.post("/clear-caches", async (req, res) => {
for (const key of keysToDelete) {
await redisClient.del(key);
}
console.log(
logger.debug(
`[CACHE] Successfully cleared ${keysToDelete.length} cache entries`
);
@@ -701,7 +790,7 @@ router.post("/clear-caches", async (req, res) => {
});
}
} catch (error: any) {
console.error("Clear caches error:", error);
logger.error("Clear caches error:", error);
res.status(500).json({
error: "Failed to clear caches",
details: error.message,
+68 -68
View File
@@ -6,15 +6,26 @@
*/
import { Router } from "express";
import { prisma } from "../utils/db";
import { scanQueue } from "../workers/queues";
import { discoverWeeklyService } from "../services/discoverWeekly";
import { simpleDownloadManager } from "../services/simpleDownloadManager";
import { queueCleaner } from "../jobs/queueCleaner";
import { getSystemSettings } from "../utils/systemSettings";
import { prisma } from "../utils/db";
import { logger } from "../utils/logger";
const router = Router();
// GET /webhooks/lidarr/verify - Webhook verification endpoint
router.get("/lidarr/verify", (req, res) => {
logger.debug("[WEBHOOK] Verification request received");
res.json({
status: "ok",
timestamp: new Date().toISOString(),
service: "lidify",
version: process.env.npm_package_version || "unknown",
});
});
// POST /webhooks/lidarr - Handle Lidarr webhooks
router.post("/lidarr", async (req, res) => {
try {
@@ -25,7 +36,7 @@ router.post("/lidarr", async (req, res) => {
!settings?.lidarrUrl ||
!settings?.lidarrApiKey
) {
console.log(
logger.debug(
`[WEBHOOK] Lidarr webhook received but Lidarr is disabled. Ignoring.`
);
return res.status(202).json({
@@ -35,12 +46,27 @@ router.post("/lidarr", async (req, res) => {
});
}
// Verify webhook secret if configured
// Note: settings.lidarrWebhookSecret is already decrypted by getSystemSettings()
if (settings.lidarrWebhookSecret) {
const providedSecret = req.headers["x-webhook-secret"] as string;
if (!providedSecret || providedSecret !== settings.lidarrWebhookSecret) {
logger.debug(
`[WEBHOOK] Lidarr webhook received with invalid or missing secret`
);
return res.status(401).json({
error: "Unauthorized - Invalid webhook secret",
});
}
}
const eventType = req.body.eventType;
console.log(`[WEBHOOK] Lidarr event: ${eventType}`);
logger.debug(`[WEBHOOK] Lidarr event: ${eventType}`);
// Log payload in debug mode only (avoid verbose logs in production)
if (process.env.DEBUG_WEBHOOKS === "true") {
console.log(` Payload:`, JSON.stringify(req.body, null, 2));
logger.debug(` Payload:`, JSON.stringify(req.body, null, 2));
}
switch (eventType) {
@@ -68,16 +94,16 @@ router.post("/lidarr", async (req, res) => {
break;
case "Test":
console.log(" Lidarr test webhook received");
logger.debug(" Lidarr test webhook received");
break;
default:
console.log(` Unhandled event: ${eventType}`);
logger.debug(` Unhandled event: ${eventType}`);
}
res.json({ success: true });
} catch (error: any) {
console.error("Webhook error:", error.message);
logger.error("Webhook error:", error.message);
res.status(500).json({ error: "Webhook processing failed" });
}
});
@@ -93,12 +119,12 @@ async function handleGrab(payload: any) {
const artistName = payload.artist?.name;
const lidarrAlbumId = payload.albums?.[0]?.id;
console.log(` Album: ${artistName} - ${albumTitle}`);
console.log(` Download ID: ${downloadId}`);
console.log(` MBID: ${albumMbid}`);
logger.debug(` Album: ${artistName} - ${albumTitle}`);
logger.debug(` Download ID: ${downloadId}`);
logger.debug(` MBID: ${albumMbid}`);
if (!downloadId) {
console.log(` Missing downloadId, skipping`);
logger.debug(` Missing downloadId, skipping`);
return;
}
@@ -128,13 +154,13 @@ async function handleDownload(payload: any) {
payload.album?.foreignAlbumId || payload.albums?.[0]?.foreignAlbumId;
const lidarrAlbumId = payload.album?.id || payload.albums?.[0]?.id;
console.log(` Album: ${artistName} - ${albumTitle}`);
console.log(` Download ID: ${downloadId}`);
console.log(` Album MBID: ${albumMbid}`);
console.log(` Lidarr Album ID: ${lidarrAlbumId}`);
logger.debug(` Album: ${artistName} - ${albumTitle}`);
logger.debug(` Download ID: ${downloadId}`);
logger.debug(` Album MBID: ${albumMbid}`);
logger.debug(` Lidarr Album ID: ${lidarrAlbumId}`);
if (!downloadId) {
console.log(` Missing downloadId, skipping`);
logger.debug(` Missing downloadId, skipping`);
return;
}
@@ -148,36 +174,30 @@ async function handleDownload(payload: any) {
);
if (result.jobId) {
// Check if this is part of a download batch (artist download)
if (result.downloadBatchId) {
// Check if all jobs in the batch are complete
const batchComplete = await checkDownloadBatchComplete(
result.downloadBatchId
);
if (batchComplete) {
console.log(
` All albums in batch complete, triggering library scan...`
);
await scanQueue.add("scan", {
type: "full",
source: "lidarr-import-batch",
});
} else {
console.log(` Batch not complete, skipping scan`);
}
} else if (!result.batchId) {
// Single album download (not part of discovery batch)
console.log(` Triggering library scan...`);
await scanQueue.add("scan", {
type: "full",
source: "lidarr-import",
});
}
// If part of discovery batch, the download manager already called checkBatchCompletion
// Find the download job that triggered this webhook to get userId
const downloadJob = await prisma.downloadJob.findUnique({
where: { id: result.jobId },
select: { userId: true, id: true },
});
// Trigger scan immediately for this album (incremental scan with enrichment data)
// Don't wait for batch completion - enrichment should happen per-album
logger.debug(
` Triggering incremental scan for: ${artistName} - ${albumTitle}`
);
await scanQueue.add("scan", {
userId: downloadJob?.userId || null,
source: "lidarr-webhook",
artistName: artistName,
albumMbid: albumMbid,
downloadId: result.jobId,
});
// Discovery batch completion (for playlist building) is handled by download manager
} else {
// No job found - this might be an external download not initiated by us
// Still trigger a scan to pick up the new music
console.log(` No matching job, triggering scan anyway...`);
logger.debug(` No matching job, triggering scan anyway...`);
await scanQueue.add("scan", {
type: "full",
source: "lidarr-import-external",
@@ -185,26 +205,6 @@ async function handleDownload(payload: any) {
}
}
/**
* Check if all jobs in a download batch are complete
*/
async function checkDownloadBatchComplete(batchId: string): Promise<boolean> {
const pendingJobs = await prisma.downloadJob.count({
where: {
metadata: {
path: ["batchId"],
equals: batchId,
},
status: { in: ["pending", "processing"] },
},
});
console.log(
` Batch ${batchId}: ${pendingJobs} pending/processing jobs remaining`
);
return pendingJobs === 0;
}
/**
* Handle import failure with automatic retry
*/
@@ -215,12 +215,12 @@ async function handleImportFailure(payload: any) {
const albumTitle = payload.album?.title || payload.release?.title;
const reason = payload.message || "Import failed";
console.log(` Album: ${albumTitle}`);
console.log(` Download ID: ${downloadId}`);
console.log(` Reason: ${reason}`);
logger.debug(` Album: ${albumTitle}`);
logger.debug(` Download ID: ${downloadId}`);
logger.debug(` Reason: ${reason}`);
if (!downloadId) {
console.log(` Missing downloadId, skipping`);
logger.debug(` Missing downloadId, skipping`);
return;
}