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:
@@ -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;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
|
||||
+452
-248
File diff suppressed because it is too large
Load Diff
+232
-65
@@ -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" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
|
||||
+548
-363
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user