Initial release v1.0.0

This commit is contained in:
Kevin O'Neill
2025-12-25 18:58:06 -06:00
commit 021aec7a63
439 changed files with 116588 additions and 0 deletions
+293
View File
@@ -0,0 +1,293 @@
import { Router } from "express";
import { prisma } from "../utils/db";
import { redisClient } from "../utils/redis";
import { requireAuth, requireAdmin } from "../middleware/auth";
const router = Router();
// Redis queue key for audio analysis
const ANALYSIS_QUEUE = "audio:analysis:queue";
/**
* GET /api/analysis/status
* Get audio analysis status and progress
*/
router.get("/status", requireAuth, async (req, res) => {
try {
// Get counts by status
const statusCounts = await prisma.track.groupBy({
by: ["analysisStatus"],
_count: true,
});
const total = statusCounts.reduce((sum, s) => sum + s._count, 0);
const completed = statusCounts.find(s => s.analysisStatus === "completed")?._count || 0;
const failed = statusCounts.find(s => s.analysisStatus === "failed")?._count || 0;
const processing = statusCounts.find(s => s.analysisStatus === "processing")?._count || 0;
const pending = statusCounts.find(s => s.analysisStatus === "pending")?._count || 0;
// Get queue length from Redis
const queueLength = await redisClient.lLen(ANALYSIS_QUEUE);
const progress = total > 0 ? Math.round((completed / total) * 100) : 0;
res.json({
total,
completed,
failed,
processing,
pending,
queueLength,
progress,
isComplete: pending === 0 && processing === 0 && queueLength === 0,
});
} catch (error: any) {
console.error("Analysis status error:", error);
res.status(500).json({ error: "Failed to get analysis status" });
}
});
/**
* POST /api/analysis/start
* Start audio analysis for pending tracks (admin only)
*/
router.post("/start", requireAuth, requireAdmin, async (req, res) => {
try {
const { limit = 100, priority = "recent" } = req.body;
// Find pending tracks
const tracks = await prisma.track.findMany({
where: {
analysisStatus: "pending",
},
select: {
id: true,
filePath: true,
},
orderBy: priority === "recent"
? { fileModified: "desc" }
: { title: "asc" },
take: Math.min(limit, 1000),
});
if (tracks.length === 0) {
return res.json({
message: "No pending tracks to analyze",
queued: 0,
});
}
// Queue tracks for analysis
const pipeline = redisClient.multi();
for (const track of tracks) {
pipeline.rPush(ANALYSIS_QUEUE, JSON.stringify({
trackId: track.id,
filePath: track.filePath,
}));
}
await pipeline.exec();
console.log(`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);
res.status(500).json({ error: "Failed to start analysis" });
}
});
/**
* POST /api/analysis/retry-failed
* Retry failed analysis jobs (admin only)
*/
router.post("/retry-failed", requireAuth, requireAdmin, async (req, res) => {
try {
// Reset failed tracks to pending
const result = await prisma.track.updateMany({
where: {
analysisStatus: "failed",
},
data: {
analysisStatus: "pending",
analysisError: null,
},
});
res.json({
message: `Reset ${result.count} failed tracks to pending`,
reset: result.count,
});
} catch (error: any) {
console.error("Retry failed error:", error);
res.status(500).json({ error: "Failed to retry analysis" });
}
});
/**
* POST /api/analysis/analyze/:trackId
* Queue a specific track for analysis
*/
router.post("/analyze/:trackId", requireAuth, async (req, res) => {
try {
const { trackId } = req.params;
const track = await prisma.track.findUnique({
where: { id: trackId },
select: {
id: true,
filePath: true,
analysisStatus: true,
},
});
if (!track) {
return res.status(404).json({ error: "Track not found" });
}
// Queue for analysis
await redisClient.rPush(ANALYSIS_QUEUE, JSON.stringify({
trackId: track.id,
filePath: track.filePath,
}));
// Mark as pending if not already
if (track.analysisStatus !== "processing") {
await prisma.track.update({
where: { id: trackId },
data: { analysisStatus: "pending" },
});
}
res.json({
message: "Track queued for analysis",
trackId,
});
} catch (error: any) {
console.error("Analyze track error:", error);
res.status(500).json({ error: "Failed to queue track for analysis" });
}
});
/**
* GET /api/analysis/track/:trackId
* Get analysis data for a specific track
*/
router.get("/track/:trackId", requireAuth, async (req, res) => {
try {
const { trackId } = req.params;
const track = await prisma.track.findUnique({
where: { id: trackId },
select: {
id: true,
title: true,
analysisStatus: true,
analysisError: true,
analyzedAt: true,
analysisVersion: true,
bpm: true,
beatsCount: true,
key: true,
keyScale: true,
keyStrength: true,
energy: true,
loudness: true,
dynamicRange: true,
danceability: true,
valence: true,
arousal: true,
instrumentalness: true,
acousticness: true,
speechiness: true,
moodTags: true,
essentiaGenres: true,
lastfmTags: true,
},
});
if (!track) {
return res.status(404).json({ error: "Track not found" });
}
res.json(track);
} catch (error: any) {
console.error("Get track analysis error:", error);
res.status(500).json({ error: "Failed to get track analysis" });
}
});
/**
* GET /api/analysis/features
* Get aggregated feature statistics for the library
*/
router.get("/features", requireAuth, async (req, res) => {
try {
// Get analyzed tracks
const analyzed = await prisma.track.findMany({
where: {
analysisStatus: "completed",
bpm: { not: null },
},
select: {
bpm: true,
energy: true,
danceability: true,
valence: true,
keyScale: true,
},
});
if (analyzed.length === 0) {
return res.json({
count: 0,
averages: null,
distributions: null,
});
}
// Calculate averages
const avgBpm = analyzed.reduce((sum, t) => sum + (t.bpm || 0), 0) / analyzed.length;
const avgEnergy = analyzed.reduce((sum, t) => sum + (t.energy || 0), 0) / analyzed.length;
const avgDanceability = analyzed.reduce((sum, t) => sum + (t.danceability || 0), 0) / analyzed.length;
const avgValence = analyzed.reduce((sum, t) => sum + (t.valence || 0), 0) / analyzed.length;
// Key distribution
const majorCount = analyzed.filter(t => t.keyScale === "major").length;
const minorCount = analyzed.filter(t => t.keyScale === "minor").length;
// BPM distribution (buckets)
const bpmBuckets = {
slow: analyzed.filter(t => (t.bpm || 0) < 90).length,
moderate: analyzed.filter(t => (t.bpm || 0) >= 90 && (t.bpm || 0) < 120).length,
upbeat: analyzed.filter(t => (t.bpm || 0) >= 120 && (t.bpm || 0) < 150).length,
fast: analyzed.filter(t => (t.bpm || 0) >= 150).length,
};
res.json({
count: analyzed.length,
averages: {
bpm: Math.round(avgBpm),
energy: Math.round(avgEnergy * 100) / 100,
danceability: Math.round(avgDanceability * 100) / 100,
valence: Math.round(avgValence * 100) / 100,
},
distributions: {
key: { major: majorCount, minor: minorCount },
bpm: bpmBuckets,
},
});
} catch (error: any) {
console.error("Get features error:", error);
res.status(500).json({ error: "Failed to get feature statistics" });
}
});
export default router;
+231
View File
@@ -0,0 +1,231 @@
import { Router } from "express";
import { requireAuth } from "../middleware/auth";
import { prisma } from "../utils/db";
import crypto from "crypto";
const router = Router();
// All API key routes require authentication (session-based)
router.use(requireAuth);
/**
* @openapi
* /api-keys:
* post:
* summary: Create a new API key for mobile/external authentication
* tags: [API Keys]
* security:
* - sessionAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - deviceName
* properties:
* deviceName:
* type: string
* description: Name of the device (e.g., "iPhone 14", "Android Tablet")
* example: "iPhone 14"
* responses:
* 201:
* description: API key created successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* apiKey:
* type: string
* description: The generated API key (64-character hex string)
* example: "a1b2c3d4e5f6..."
* name:
* type: string
* example: "iPhone 14"
* createdAt:
* type: string
* format: date-time
* message:
* type: string
* example: "API key created successfully. Save this key - you won't see it again!"
* 400:
* description: Invalid request
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Not authenticated
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
router.post("/", async (req, res) => {
try {
const { deviceName } = req.body;
if (!deviceName || deviceName.trim().length === 0) {
return res.status(400).json({ error: "Device name is required" });
}
// Use req.user.id (set by requireAuth middleware) - supports both session and JWT auth
const userId = req.user?.id || req.session?.userId;
if (!userId) {
return res.status(401).json({ error: "Not authenticated" });
}
// Generate a secure random API key (32 bytes = 64 hex chars)
const apiKeyValue = crypto.randomBytes(32).toString("hex");
const apiKey = await prisma.apiKey.create({
data: {
userId,
name: deviceName.trim(),
key: apiKeyValue,
},
});
console.log(`API key created for user ${userId}: ${deviceName}`);
res.status(201).json({
apiKey: apiKey.key,
name: apiKey.name,
createdAt: apiKey.createdAt,
message:
"API key created successfully. Save this key - you won't see it again!",
});
} catch (error) {
console.error("Create API key error:", error);
res.status(500).json({ error: "Failed to create API key" });
}
});
/**
* @openapi
* /api-keys:
* get:
* summary: List all API keys for the current user
* tags: [API Keys]
* security:
* - sessionAuth: []
* responses:
* 200:
* description: List of API keys (without the actual key values for security)
* content:
* application/json:
* schema:
* type: object
* properties:
* apiKeys:
* type: array
* items:
* $ref: '#/components/schemas/ApiKey'
* 401:
* description: Not authenticated
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
router.get("/", async (req, res) => {
try {
// Use req.user.id (set by requireAuth middleware) - supports both session and JWT auth
const userId = req.user?.id || req.session?.userId;
if (!userId) {
return res.status(401).json({ error: "Not authenticated" });
}
const keys = await prisma.apiKey.findMany({
where: { userId },
select: {
id: true,
name: true,
lastUsed: true,
createdAt: true,
// Don't return the actual key for security!
},
orderBy: { createdAt: "desc" },
});
res.json({ apiKeys: keys });
} catch (error) {
console.error("List API keys error:", error);
res.status(500).json({ error: "Failed to list API keys" });
}
});
/**
* @openapi
* /api-keys/{id}:
* delete:
* summary: Revoke an API key
* tags: [API Keys]
* security:
* - sessionAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* description: The API key ID
* responses:
* 200:
* description: API key revoked successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: "API key revoked successfully"
* 404:
* description: API key not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Not authenticated
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
router.delete("/:id", async (req, res) => {
try {
// Use req.user.id (set by requireAuth middleware) - supports both session and JWT auth
const userId = req.user?.id || req.session?.userId;
if (!userId) {
return res.status(401).json({ error: "Not authenticated" });
}
const keyId = req.params.id;
// Only allow users to delete their own keys
const deleted = await prisma.apiKey.deleteMany({
where: {
id: keyId,
userId,
},
});
if (deleted.count === 0) {
return res
.status(404)
.json({ error: "API key not found or already deleted" });
}
console.log(`API key ${keyId} revoked by user ${userId}`);
res.json({ message: "API key revoked successfully" });
} catch (error) {
console.error("Delete API key error:", error);
res.status(500).json({ error: "Failed to revoke API key" });
}
});
export default router;
+566
View File
@@ -0,0 +1,566 @@
import { Router } from "express";
import { lastFmService } from "../services/lastfm";
import { musicBrainzService } from "../services/musicbrainz";
import { fanartService } from "../services/fanart";
import { deezerService } from "../services/deezer";
import { redisClient } from "../utils/redis";
const router = Router();
// Cache TTL for discovery content (shorter since it's not owned)
const DISCOVERY_CACHE_TTL = 24 * 60 * 60; // 24 hours
// GET /artists/preview/:artistName/:trackTitle - Get Deezer preview URL for a track
router.get("/preview/:artistName/:trackTitle", async (req, res) => {
try {
const { artistName, trackTitle } = req.params;
const decodedArtist = decodeURIComponent(artistName);
const decodedTrack = decodeURIComponent(trackTitle);
console.log(
`Getting preview for "${decodedTrack}" by ${decodedArtist}`
);
const previewUrl = await deezerService.getTrackPreview(
decodedArtist,
decodedTrack
);
if (previewUrl) {
res.json({ previewUrl });
} else {
res.status(404).json({ error: "Preview not found" });
}
} catch (error: any) {
console.error("Preview fetch error:", error);
res.status(500).json({
error: "Failed to fetch preview",
message: error.message,
});
}
});
// GET /artists/discover/:nameOrMbid - Get artist details for discovery (not in library yet)
router.get("/discover/:nameOrMbid", async (req, res) => {
try {
const { nameOrMbid } = req.params;
// Check Redis cache first for discovery content
const cacheKey = `discovery:artist:${nameOrMbid}`;
try {
const cached = await redisClient.get(cacheKey);
if (cached) {
console.log(`[Discovery] Cache hit for artist: ${nameOrMbid}`);
return res.json(JSON.parse(cached));
}
} catch (err) {
// Redis errors are non-critical
}
// Check if it's an MBID (UUID format) or name
const isMbid =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
nameOrMbid
);
let mbid: string | null = isMbid ? nameOrMbid : null;
let artistName: string = isMbid ? "" : decodeURIComponent(nameOrMbid);
// If we have a name but no MBID, search for it
if (!mbid && artistName) {
const mbResults = await musicBrainzService.searchArtist(
artistName,
1
);
if (mbResults.length > 0) {
mbid = mbResults[0].id;
artistName = mbResults[0].name;
}
}
// If we have MBID but no name, get it from MusicBrainz
if (mbid && !artistName) {
const mbArtist = await musicBrainzService.getArtist(mbid);
artistName = mbArtist.name;
}
if (!artistName) {
return res.status(404).json({ error: "Artist not found" });
}
// Get artist info from Last.fm
const lastFmInfo = await lastFmService.getArtistInfo(
artistName,
mbid || undefined
);
// Filter out generic "multiple artists" biographies from Last.fm
// These occur when Last.fm groups artists with the same name
let bio = lastFmInfo?.bio?.summary || null;
if (bio) {
const lowerBio = bio.toLowerCase();
if (
(lowerBio.includes("there are") &&
(lowerBio.includes("artist") ||
lowerBio.includes("band")) &&
lowerBio.includes("with the name")) ||
lowerBio.includes("there is more than one artist") ||
lowerBio.includes("multiple artists")
) {
// This is a disambiguation page - don't show it
console.log(
` Filtered out disambiguation biography for ${artistName}`
);
bio = null;
}
}
// Get top tracks from Last.fm
let topTracks: any[] = [];
if (mbid || artistName) {
try {
topTracks = await lastFmService.getArtistTopTracks(
mbid || "",
artistName,
10
);
} catch (error) {
console.log(`Failed to get top tracks for ${artistName}`);
}
}
// Get artist image
let image = null;
// Try Fanart.tv first (if we have MBID)
if (mbid) {
try {
image = await fanartService.getArtistImage(mbid);
console.log(`Fanart.tv image for ${artistName}`);
} catch (error) {
console.log(
`✗ Failed to get Fanart.tv image for ${artistName}`
);
}
}
// Fallback to Deezer
if (!image) {
try {
image = await deezerService.getArtistImage(artistName);
if (image) {
console.log(`Deezer image for ${artistName}`);
}
} catch (error) {
console.log(`✗ Failed to get Deezer image for ${artistName}`);
}
}
// Fallback to Last.fm (but filter placeholders)
if (!image && lastFmInfo?.image) {
const lastFmImage = lastFmService.getBestImage(lastFmInfo.image);
// Filter out Last.fm placeholder
if (
lastFmImage &&
!lastFmImage.includes("2a96cbd8b46e442fc41c2b86b821562f")
) {
image = lastFmImage;
console.log(`Last.fm image for ${artistName}`);
} else {
console.log(`✗ Last.fm returned placeholder for ${artistName}`);
}
}
// Get discography from MusicBrainz
let albums: any[] = [];
if (mbid) {
try {
const releaseGroups = await musicBrainzService.getReleaseGroups(
mbid
);
// Filter albums - only show studio albums and EPs
// Exclude live albums, compilations, soundtracks, remixes, etc.
const filteredReleaseGroups = releaseGroups.filter(
(rg: any) => {
// Must be Album or EP
const isPrimaryType =
rg["primary-type"] === "Album" ||
rg["primary-type"] === "EP";
if (!isPrimaryType) return false;
// Exclude secondary types (live, compilation, soundtrack, remix, etc.)
const secondaryTypes = rg["secondary-types"] || [];
const hasExcludedType = secondaryTypes.some(
(type: string) =>
[
"Live",
"Compilation",
"Soundtrack",
"Remix",
"DJ-mix",
"Mixtape/Street",
].includes(type)
);
return !hasExcludedType;
}
);
// Process albums with Deezer fallback
albums = await Promise.all(
filteredReleaseGroups.map(async (rg: any) => {
// Default to Cover Art Archive URL
let coverUrl = `https://coverartarchive.org/release-group/${rg.id}/front-500`;
// For first 10 albums, try Deezer as fallback if Cover Art Archive doesn't have it
// (to avoid too many requests)
const index = filteredReleaseGroups.indexOf(rg);
if (index < 10) {
try {
const response = await fetch(coverUrl, {
method: "HEAD",
signal: AbortSignal.timeout(2000),
});
if (!response.ok) {
// Cover Art Archive doesn't have it, try Deezer
const deezerCover =
await deezerService.getAlbumCover(
artistName,
rg.title
);
if (deezerCover) {
coverUrl = deezerCover;
}
}
} catch (error) {
// Silently fail and keep Cover Art Archive URL
}
}
return {
id: rg.id, // MBID - used for linking
rgMbid: rg.id, // Release group MBID - used for downloads
mbid: rg.id, // Fallback MBID
title: rg.title,
type: rg["primary-type"],
year: rg["first-release-date"]
? parseInt(
rg["first-release-date"].substring(0, 4)
)
: null,
releaseDate: rg["first-release-date"] || null,
coverUrl,
owned: false, // Discovery albums are never owned
};
})
);
// Sort albums
albums.sort((a: any, b: any) => {
// Sort by year descending (newest first)
if (a.year && b.year) return b.year - a.year;
if (a.year) return -1;
if (b.year) return 1;
return 0;
});
} catch (error) {
console.error(
`Failed to get discography for ${artistName}:`,
error
);
}
}
// Get similar artists from Last.fm and fetch images
const similarArtistsRaw = lastFmInfo?.similar?.artist || [];
const similarArtists = await Promise.all(
similarArtistsRaw.slice(0, 10).map(async (artist: any) => {
const similarImage = artist.image?.find(
(img: any) => img.size === "large"
)?.[" #text"];
let image = null;
// Try Fanart.tv first (if we have MBID)
if (artist.mbid) {
try {
image = await fanartService.getArtistImage(artist.mbid);
} catch (error) {
// Silently fail
}
}
// Fallback to Deezer
if (!image) {
try {
const deezerImage = await deezerService.getArtistImage(
artist.name
);
if (deezerImage) {
image = deezerImage;
}
} catch (error) {
// Silently fail
}
}
// Last fallback to Last.fm (but filter placeholders)
if (
!image &&
similarImage &&
!similarImage.includes("2a96cbd8b46e442fc41c2b86b821562f")
) {
image = similarImage;
}
return {
id: artist.mbid || artist.name,
name: artist.name,
mbid: artist.mbid || null,
url: artist.url,
image,
};
})
);
const response = {
mbid,
name: artistName,
image,
bio, // Use filtered bio instead of raw Last.fm bio
summary: bio, // Alias for consistency
tags: lastFmInfo?.tags?.tag?.map((t: any) => t.name) || [],
genres: lastFmInfo?.tags?.tag?.map((t: any) => t.name) || [], // Alias for consistency
listeners: parseInt(lastFmInfo?.stats?.listeners || "0"),
playcount: parseInt(lastFmInfo?.stats?.playcount || "0"),
url: lastFmInfo?.url || null,
albums: albums.map((album) => ({ ...album, owned: false })), // Mark all as not owned
topTracks: topTracks.map((track) => ({
id: `lastfm-${mbid || artistName}-${track.name}`,
title: track.name,
playCount: parseInt(track.playcount || "0"),
listeners: parseInt(track.listeners || "0"),
duration: parseInt(track.duration || "0"),
url: track.url,
album: { title: track.album?.["#text"] || "Unknown Album" },
})),
similarArtists,
};
// Cache discovery response for 24 hours
try {
await redisClient.setEx(
cacheKey,
DISCOVERY_CACHE_TTL,
JSON.stringify(response)
);
console.log(`[Discovery] Cached artist: ${artistName}`);
} catch (err) {
// Redis errors are non-critical
}
res.json(response);
} catch (error: any) {
console.error("Artist discovery error:", error);
res.status(500).json({
error: "Failed to fetch artist details",
message: error.message,
});
}
});
// GET /artists/album/:mbid - Get album details for discovery (not in library yet)
router.get("/album/:mbid", async (req, res) => {
try {
const { mbid } = req.params;
// Check Redis cache first for discovery content
const cacheKey = `discovery:album:${mbid}`;
try {
const cached = await redisClient.get(cacheKey);
if (cached) {
console.log(`[Discovery] Cache hit for album: ${mbid}`);
return res.json(JSON.parse(cached));
}
} catch (err) {
// Redis errors are non-critical
}
let releaseGroup: any = null;
let release: any = null;
let releaseGroupId: string = mbid;
// Try as release-group first, then as release
try {
releaseGroup = await musicBrainzService.getReleaseGroup(mbid);
} catch (error: any) {
// If 404, try as a release instead
if (error.response?.status === 404) {
console.log(
`${mbid} is not a release-group, trying as release...`
);
release = await musicBrainzService.getRelease(mbid);
releaseGroupId = release["release-group"]?.id || mbid;
// Now get the release group to get the type and first-release-date
if (releaseGroupId) {
try {
releaseGroup = await musicBrainzService.getReleaseGroup(
releaseGroupId
);
} catch (err) {
console.error(
`Failed to get release-group ${releaseGroupId}`
);
}
}
} else {
throw error;
}
}
if (!releaseGroup && !release) {
return res.status(404).json({ error: "Album not found" });
}
// Get the artist name and MBID from either release-group or release
const artistCredit =
releaseGroup?.["artist-credit"] || release?.["artist-credit"];
const artistName = artistCredit?.[0]?.name || "Unknown Artist";
const artistMbid = artistCredit?.[0]?.artist?.id;
const albumTitle = releaseGroup?.title || release?.title;
// Get album info from Last.fm
let lastFmInfo = null;
try {
lastFmInfo = await lastFmService.getAlbumInfo(
artistName,
albumTitle
);
} catch (error) {
console.log(`Failed to get Last.fm info for ${albumTitle}`);
}
// Get tracks - if we have release, use it directly; otherwise get first release from group
let tracks: any[] = [];
if (release) {
tracks = release.media?.[0]?.tracks || [];
} else if (releaseGroup?.releases && releaseGroup.releases.length > 0) {
const firstRelease = releaseGroup.releases[0];
try {
const releaseDetails = await musicBrainzService.getRelease(
firstRelease.id
);
tracks = releaseDetails.media?.[0]?.tracks || [];
} catch (error) {
console.error(
`Failed to get tracks for release ${firstRelease.id}`
);
}
}
// Get album cover art - try Cover Art Archive first
let coverUrl = null;
let coverArtUrl = `https://coverartarchive.org/release/${mbid}/front-500`;
if (!release) {
coverArtUrl = `https://coverartarchive.org/release-group/${releaseGroupId}/front-500`;
}
// Check if Cover Art Archive actually has the image
try {
const response = await fetch(coverArtUrl, { method: "HEAD" });
if (response.ok) {
coverUrl = coverArtUrl;
console.log(`Cover Art Archive has cover for ${albumTitle}`);
} else {
console.log(
`✗ Cover Art Archive 404 for ${albumTitle}, trying Deezer...`
);
}
} catch (error) {
console.log(
`✗ Cover Art Archive check failed for ${albumTitle}, trying Deezer...`
);
}
// Fallback to Deezer if Cover Art Archive doesn't have it
if (!coverUrl) {
try {
const deezerCover = await deezerService.getAlbumCover(
artistName,
albumTitle
);
if (deezerCover) {
coverUrl = deezerCover;
console.log(`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}`);
// Final fallback to Cover Art Archive URL
coverUrl = coverArtUrl;
}
}
// Format response
const releaseMbid = release?.id || null;
const response = {
id: releaseGroupId,
rgMbid: releaseGroupId,
mbid: releaseMbid || releaseGroupId,
releaseMbid,
title: albumTitle,
artist: {
name: artistName,
id: artistMbid || artistName,
mbid: artistMbid,
},
year: releaseGroup?.["first-release-date"]
? parseInt(releaseGroup["first-release-date"].substring(0, 4))
: release?.date
? parseInt(release.date.substring(0, 4))
: null,
type: releaseGroup?.["primary-type"] || "Album",
coverUrl,
coverArt: coverUrl, // Alias for compatibility
bio: lastFmInfo?.wiki?.summary || null,
tags: lastFmInfo?.tags?.tag?.map((t: any) => t.name) || [],
tracks: tracks.map((track: any, index: number) => ({
id: `mb-${releaseGroupId}-${track.id || index}`,
title: track.title,
trackNo: track.position || index + 1,
duration: track.length ? Math.floor(track.length / 1000) : 0,
artist: { name: artistName },
})),
similarAlbums: [], // Similar album recommendations not yet implemented
owned: false,
source: "discovery",
};
// Cache discovery response for 24 hours
try {
await redisClient.setEx(
cacheKey,
DISCOVERY_CACHE_TTL,
JSON.stringify(response)
);
console.log(`[Discovery] Cached album: ${albumTitle}`);
} catch (err) {
// Redis errors are non-critical
}
res.json(response);
} catch (error: any) {
console.error("Album discovery error:", error);
res.status(500).json({
error: "Failed to fetch album details",
message: error.message,
});
}
});
export default router;
+907
View File
@@ -0,0 +1,907 @@
import { Router } from "express";
import { audiobookshelfService } from "../services/audiobookshelf";
import { audiobookCacheService } from "../services/audiobookCache";
import { prisma } from "../utils/db";
import { requireAuthOrToken } from "../middleware/auth";
import { imageLimiter, apiLimiter } from "../middleware/rateLimiter";
const router = Router();
/**
* GET /audiobooks/continue-listening
* Get audiobooks the user is currently listening to (for "Continue Listening" section)
* NOTE: This must come BEFORE the /:id route to avoid matching "continue-listening" as an ID
*/
router.get(
"/continue-listening",
requireAuthOrToken,
apiLimiter,
async (req, res) => {
try {
// Check if Audiobookshelf is enabled
const { getSystemSettings } = await import(
"../utils/systemSettings"
);
const settings = await getSystemSettings();
if (!settings?.audiobookshelfEnabled) {
return res.status(200).json([]);
}
const recentProgress = await prisma.audiobookProgress.findMany({
where: {
userId: req.user!.id,
isFinished: false,
currentTime: {
gt: 0,
},
},
orderBy: {
lastPlayedAt: "desc",
},
take: 10,
});
// Transform the cover URLs to use the audiobook__ prefix for the proxy
const transformed = recentProgress.map((progress: any) => {
const coverUrl =
progress.coverUrl && !progress.coverUrl.startsWith("http")
? `audiobook__${progress.coverUrl}`
: progress.coverUrl;
return {
...progress,
coverUrl,
};
});
res.json(transformed);
} catch (error: any) {
console.error("Error fetching continue listening:", error);
res.status(500).json({
error: "Failed to fetch continue listening",
message: error.message,
});
}
}
);
/**
* POST /audiobooks/sync
* Manually trigger audiobook sync from Audiobookshelf
* Fetches all audiobooks and caches metadata + cover images locally
*/
router.post("/sync", requireAuthOrToken, apiLimiter, async (req, res) => {
try {
const { getSystemSettings } = await import("../utils/systemSettings");
const { notificationService } = await import("../services/notificationService");
const settings = await getSystemSettings();
if (!settings?.audiobookshelfEnabled) {
return res
.status(400)
.json({ error: "Audiobookshelf not enabled" });
}
console.log("[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(
`[Audiobooks] Sync complete. Books with series: ${seriesCount}`
);
// Send notification to user
if (req.user?.id) {
await notificationService.notifySystem(
req.user.id,
"Audiobook Sync Complete",
`Synced ${result.synced || 0} audiobooks (${seriesCount} with series)`
);
}
res.json({
success: true,
result,
});
} catch (error: any) {
console.error("Audiobook sync failed:", error);
res.status(500).json({
error: "Sync failed",
message: error.message,
});
}
});
/**
* GET /audiobooks/debug-series
* Debug endpoint to see raw series data from Audiobookshelf
*/
// Debug endpoint for series data
router.get("/debug-series", requireAuthOrToken, async (req, res) => {
console.log("[Audiobooks] Debug series endpoint called");
try {
const { getSystemSettings } = await import("../utils/systemSettings");
const settings = await getSystemSettings();
if (!settings?.audiobookshelfEnabled) {
return res
.status(400)
.json({ error: "Audiobookshelf not enabled" });
}
// Get raw data from Audiobookshelf
const rawBooks = await audiobookshelfService.getAllAudiobooks();
console.log(
`[Audiobooks] Got ${rawBooks.length} books from Audiobookshelf`
);
// Find books with series data
const booksWithSeries = rawBooks.filter((book: any) => {
const metadata = book.media?.metadata || book;
return metadata.series || metadata.seriesName;
});
console.log(
`[Audiobooks] Books with series data: ${booksWithSeries.length}`
);
// Extract series info from all books (first 20)
const allSeriesInfo = rawBooks.slice(0, 20).map((book: any) => {
const metadata = book.media?.metadata || book;
return {
title: metadata.title || book.title,
rawSeries: metadata.series,
seriesName: metadata.seriesName,
seriesSequence: metadata.seriesSequence,
// Also check if there's series in the top-level book object
bookSeries: book.series,
};
});
// Get a full sample of one book with series (if any)
let fullSample = null;
if (booksWithSeries.length > 0) {
const sampleBook = booksWithSeries[0];
fullSample = {
id: sampleBook.id,
media: sampleBook.media,
};
}
res.json({
totalBooks: rawBooks.length,
booksWithSeriesCount: booksWithSeries.length,
sampleSeriesData: allSeriesInfo,
fullSampleWithSeries: fullSample,
});
} catch (error: any) {
console.error("[Audiobooks] Debug series error:", error);
res.status(500).json({ error: error.message });
}
});
/**
* GET /audiobooks/search
* Search audiobooks
*/
router.get("/search", requireAuthOrToken, apiLimiter, async (req, res) => {
try {
// Check if Audiobookshelf is enabled
const { getSystemSettings } = await import("../utils/systemSettings");
const settings = await getSystemSettings();
if (!settings?.audiobookshelfEnabled) {
return res.status(200).json([]);
}
const { q } = req.query;
if (!q || typeof q !== "string") {
return res.status(400).json({ error: "Query parameter required" });
}
const results = await audiobookshelfService.searchAudiobooks(q);
res.json(results);
} catch (error: any) {
console.error("Error searching audiobooks:", error);
res.status(500).json({
error: "Failed to search audiobooks",
message: error.message,
});
}
});
/**
* GET /audiobooks
* Get all audiobooks from cached database (instant, no API calls)
*/
router.get("/", requireAuthOrToken, apiLimiter, async (req, res) => {
console.log("[Audiobooks] GET / - fetching audiobooks list");
try {
// Check if Audiobookshelf is enabled first
const { getSystemSettings } = await import("../utils/systemSettings");
const settings = await getSystemSettings();
if (!settings?.audiobookshelfEnabled) {
return res.status(200).json({
configured: false,
enabled: false,
audiobooks: [],
});
}
// Read from cached database instead of hitting Audiobookshelf API
const audiobooks = await prisma.audiobook.findMany({
orderBy: { title: "asc" },
});
const audiobookIds = audiobooks.map((book) => book.id);
const progressEntries =
audiobookIds.length > 0
? await prisma.audiobookProgress.findMany({
where: {
userId: req.user!.id,
audiobookshelfId: { in: audiobookIds },
},
})
: [];
const progressMap = new Map(
progressEntries.map((entry) => [entry.audiobookshelfId, entry])
);
// Get user's progress for each audiobook
const audiobooksWithProgress = audiobooks.map((book) => {
const progress = progressMap.get(book.id);
// Cover URL: if we have localCoverPath or coverUrl from Audiobookshelf, serve from our endpoint
// The /audiobooks/:id/cover endpoint will find the file on disk even if localCoverPath isn't set
const hasCover = book.localCoverPath || book.coverUrl;
return {
id: book.id,
title: book.title,
author: book.author || "Unknown Author",
narrator: book.narrator,
description: book.description,
coverUrl: hasCover
? `/audiobooks/${book.id}/cover` // Serve from local disk
: null,
duration: book.duration || 0,
libraryId: book.libraryId,
series: book.series
? {
name: book.series,
sequence: book.seriesSequence || "1",
}
: null,
genres: book.genres || [],
progress: progress
? {
currentTime: progress.currentTime,
progress:
progress.duration > 0
? (progress.currentTime / progress.duration) *
100
: 0,
isFinished: progress.isFinished,
lastPlayedAt: progress.lastPlayedAt,
}
: null,
};
});
res.json(audiobooksWithProgress);
} catch (error: any) {
console.error("Error fetching audiobooks:", error);
res.status(500).json({
error: "Failed to fetch audiobooks",
message: error.message,
});
}
});
/**
* GET /audiobooks/series/:seriesName
* Get all books in a series (from cached database)
*/
router.get(
"/series/:seriesName",
requireAuthOrToken,
apiLimiter,
async (req, res) => {
try {
// Check if Audiobookshelf is enabled
const { getSystemSettings } = await import(
"../utils/systemSettings"
);
const settings = await getSystemSettings();
if (!settings?.audiobookshelfEnabled) {
return res.status(200).json([]);
}
const { seriesName } = req.params;
const decodedSeriesName = decodeURIComponent(seriesName);
// Read from cached database
const audiobooks = await prisma.audiobook.findMany({
where: {
series: decodedSeriesName,
},
orderBy: {
seriesSequence: "asc",
},
});
const seriesIds = audiobooks.map((book) => book.id);
const seriesProgressEntries =
seriesIds.length > 0
? await prisma.audiobookProgress.findMany({
where: {
userId: req.user!.id,
audiobookshelfId: { in: seriesIds },
},
})
: [];
const seriesProgressMap = new Map(
seriesProgressEntries.map((entry) => [
entry.audiobookshelfId,
entry,
])
);
const seriesBooks = audiobooks.map((book) => {
const progress = seriesProgressMap.get(book.id);
return {
id: book.id,
title: book.title,
author: book.author || "Unknown Author",
narrator: book.narrator,
description: book.description,
coverUrl:
book.localCoverPath || book.coverUrl
? `/audiobooks/${book.id}/cover`
: null,
duration: book.duration || 0,
libraryId: book.libraryId,
series: book.series
? {
name: book.series,
sequence: book.seriesSequence || "1",
}
: null,
genres: book.genres || [],
progress: progress
? {
currentTime: progress.currentTime,
progress:
progress.duration > 0
? (progress.currentTime /
progress.duration) *
100
: 0,
isFinished: progress.isFinished,
lastPlayedAt: progress.lastPlayedAt,
}
: null,
};
});
res.json(seriesBooks);
} catch (error: any) {
console.error("Error fetching series:", error);
res.status(500).json({
error: "Failed to fetch series",
message: error.message,
});
}
}
);
/**
* OPTIONS /audiobooks/:id/cover
* Handle CORS preflight request for cover images
*/
router.options("/:id/cover", (req, res) => {
const origin = req.headers.origin || "http://localhost:3030";
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
res.setHeader("Access-Control-Max-Age", "86400"); // 24 hours
res.status(204).end();
});
/**
* GET /audiobooks/:id/cover
* Serve cached cover image from local disk (instant, no proxying)
* NO RATE LIMITING - These are static files served from disk with aggressive caching
*/
router.get("/:id/cover", async (req, res) => {
try {
const { id } = req.params;
const fs = await import("fs");
const path = await import("path");
const { config } = await import("../config");
const audiobook = await prisma.audiobook.findUnique({
where: { id },
select: { localCoverPath: true },
});
let coverPath = audiobook?.localCoverPath;
// Fallback: check if cover exists on disk even if DB path is empty
if (!coverPath) {
const fallbackPath = path.join(
config.music.musicPath,
"cover-cache",
"audiobooks",
`${id}.jpg`
);
if (fs.existsSync(fallbackPath)) {
coverPath = fallbackPath;
// Update database with the correct path
await prisma.audiobook
.update({
where: { id },
data: { localCoverPath: fallbackPath },
})
.catch(() => {}); // Ignore errors if audiobook doesn't exist
}
}
if (!coverPath) {
return res.status(404).json({ error: "Cover not found" });
}
// Verify file exists before sending
if (!fs.existsSync(coverPath)) {
return res.status(404).json({ error: "Cover file missing" });
}
// 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);
} catch (error: any) {
console.error("Error serving cover:", error);
res.status(500).json({
error: "Failed to serve cover",
message: error.message,
});
}
});
/**
* GET /audiobooks/:id
* Get a specific audiobook with full details (from cache, fallback to API)
*/
router.get("/:id", requireAuthOrToken, apiLimiter, async (req, res) => {
try {
// Check if Audiobookshelf is enabled
const { getSystemSettings } = await import("../utils/systemSettings");
const settings = await getSystemSettings();
if (!settings?.audiobookshelfEnabled) {
return res.status(200).json({ configured: false, enabled: false });
}
const { id } = req.params;
// Try to get from cache first
let audiobook = await prisma.audiobook.findUnique({
where: { id },
});
// If not cached or stale, fetch from API and cache it
if (
!audiobook ||
audiobook.lastSyncedAt <
new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
) {
console.log(
`[AUDIOBOOK] Audiobook ${id} not cached or stale, fetching...`
);
audiobook = await audiobookCacheService.getAudiobook(id);
}
// Get chapters and audio files from API (these change less frequently)
let absBook;
try {
absBook = await audiobookshelfService.getAudiobook(id);
} catch (apiError: any) {
console.warn(
` Failed to fetch live data from Audiobookshelf for ${id}, using cached data only:`,
apiError.message
);
// Continue with cached data only if API call fails
absBook = { media: { chapters: [], audioFiles: [] } };
}
// Get user's progress
const progress = await prisma.audiobookProgress.findUnique({
where: {
userId_audiobookshelfId: {
userId: req.user!.id,
audiobookshelfId: id,
},
},
});
const response = {
id: audiobook.id,
title: audiobook.title,
author: audiobook.author || "Unknown Author",
narrator: audiobook.narrator,
description: audiobook.description,
coverUrl:
audiobook.localCoverPath || audiobook.coverUrl
? `/audiobooks/${audiobook.id}/cover`
: null,
duration: audiobook.duration || 0,
chapters: absBook.media?.chapters || [],
audioFiles: absBook.media?.audioFiles || [],
libraryId: audiobook.libraryId,
progress: progress
? {
currentTime: progress.currentTime,
progress:
progress.duration > 0
? (progress.currentTime / progress.duration) * 100
: 0,
isFinished: progress.isFinished,
lastPlayedAt: progress.lastPlayedAt,
}
: null,
};
res.json(response);
} catch (error: any) {
console.error("Error fetching audiobook__", error);
res.status(500).json({
error: "Failed to fetch audiobook",
message: error.message,
});
}
});
/**
* GET /audiobooks/:id/stream
* Proxy the audiobook stream with authentication
*/
router.get("/:id/stream", requireAuthOrToken, async (req, res) => {
try {
console.log(
`[Audiobook Stream] Request for audiobook: ${req.params.id}`
);
console.log(`[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");
return res
.status(503)
.json({ error: "Audiobookshelf is not configured" });
}
const { id } = req.params;
const rangeHeader = req.headers.range as string | undefined;
console.log(
`[Audiobook Stream] Fetching stream for ${id}, range: ${
rangeHeader || "none"
}`
);
const { stream, headers, status } =
await audiobookshelfService.streamAudiobook(id, rangeHeader);
console.log(
`[Audiobook Stream] Got stream, status: ${status}, content-type: ${headers["content-type"]}`
);
const responseStatus = status || (rangeHeader ? 206 : 200);
res.status(responseStatus);
// Set content type - ensure it's audio
const contentType = headers["content-type"] || "audio/mpeg";
res.setHeader("Content-Type", contentType);
// Set other headers
if (headers["content-length"]) {
res.setHeader("Content-Length", headers["content-length"]);
}
if (headers["accept-ranges"]) {
res.setHeader("Accept-Ranges", headers["accept-ranges"]);
} else {
res.setHeader("Accept-Ranges", "bytes");
}
if (headers["content-range"]) {
res.setHeader("Content-Range", headers["content-range"]);
}
res.setHeader("Cache-Control", "public, max-age=0");
// Clean up upstream stream when client disconnects (e.g., skips track, closes browser)
res.on("close", () => {
if (!stream.destroyed) {
stream.destroy();
}
});
stream.pipe(res);
stream.on("error", (error: any) => {
console.error("[Audiobook Stream] Stream error:", error);
if (!res.headersSent) {
res.status(500).json({
error: "Failed to stream audiobook",
message: error.message,
});
} else {
res.end();
}
});
} catch (error: any) {
console.error("[Audiobook Stream] Error:", error.message);
res.status(500).json({
error: "Failed to stream audiobook",
message: error.message,
});
}
});
/**
* POST /audiobooks/:id/progress
* Update playback progress for an audiobook
*/
router.post(
"/:id/progress",
requireAuthOrToken,
apiLimiter,
async (req, res) => {
try {
// Check if Audiobookshelf is enabled
const { getSystemSettings } = await import(
"../utils/systemSettings"
);
const settings = await getSystemSettings();
if (!settings?.audiobookshelfEnabled) {
return res.status(200).json({
success: false,
message: "Audiobookshelf is not configured",
});
}
const { id } = req.params;
const {
currentTime: rawCurrentTime,
duration: rawDuration,
isFinished,
} = req.body;
const currentTime =
typeof rawCurrentTime === "number" &&
Number.isFinite(rawCurrentTime)
? Math.max(0, rawCurrentTime)
: 0;
const durationValue =
typeof rawDuration === "number" && Number.isFinite(rawDuration)
? 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(
` Current Time: ${currentTime}s (${Math.floor(
currentTime / 60
)} mins)`
);
console.log(
` Duration: ${durationValue}s (${Math.floor(
durationValue / 60
)} mins)`
);
if (durationValue > 0) {
console.log(
` Progress: ${(
(currentTime / durationValue) *
100
).toFixed(1)}%`
);
} else {
console.log(" Progress: duration unknown");
}
console.log(` Finished: ${!!isFinished}`);
// Pull cached metadata to avoid hitting Audiobookshelf for every update
const [cachedAudiobook, existingProgress] = await Promise.all([
prisma.audiobook.findUnique({
where: { id },
select: {
title: true,
author: true,
coverUrl: true,
duration: true,
libraryId: true,
localCoverPath: true,
},
}),
prisma.audiobookProgress.findUnique({
where: {
userId_audiobookshelfId: {
userId: req.user!.id,
audiobookshelfId: id,
},
},
}),
]);
const fallbackDuration =
durationValue ||
cachedAudiobook?.duration ||
existingProgress?.duration ||
0;
const metadataTitle =
cachedAudiobook?.title ||
existingProgress?.title ||
"Unknown Title";
const metadataAuthor =
cachedAudiobook?.author ||
existingProgress?.author ||
"Unknown Author";
const metadataCover =
cachedAudiobook?.coverUrl || existingProgress?.coverUrl || null;
// Update progress in our database
const progress = await prisma.audiobookProgress.upsert({
where: {
userId_audiobookshelfId: {
userId: req.user!.id,
audiobookshelfId: id,
},
},
create: {
userId: req.user!.id,
audiobookshelfId: id,
title: metadataTitle,
author: metadataAuthor,
coverUrl: metadataCover,
currentTime,
duration: fallbackDuration,
isFinished: !!isFinished,
lastPlayedAt: new Date(),
},
update: {
title: metadataTitle,
author: metadataAuthor,
coverUrl: metadataCover,
currentTime,
duration: fallbackDuration,
isFinished: !!isFinished,
lastPlayedAt: new Date(),
},
});
console.log(` Progress saved to database`);
// Also update progress in Audiobookshelf
try {
await audiobookshelfService.updateProgress(
id,
currentTime,
fallbackDuration,
isFinished
);
console.log(` Progress synced to Audiobookshelf`);
} catch (error) {
console.error(
"Failed to sync progress to Audiobookshelf:",
error
);
// Continue anyway - local progress is saved
}
res.json({
success: true,
progress: {
currentTime: progress.currentTime,
progress:
progress.duration > 0
? (progress.currentTime / progress.duration) * 100
: 0,
isFinished: progress.isFinished,
},
});
} catch (error: any) {
console.error("Error updating progress:", error);
res.status(500).json({
error: "Failed to update progress",
message: error.message,
});
}
}
);
/**
* DELETE /audiobooks/:id/progress
* Remove/reset progress for an audiobook
*/
router.delete(
"/:id/progress",
requireAuthOrToken,
apiLimiter,
async (req, res) => {
try {
// Check if Audiobookshelf is enabled
const { getSystemSettings } = await import(
"../utils/systemSettings"
);
const settings = await getSystemSettings();
if (!settings?.audiobookshelfEnabled) {
return res.status(200).json({
success: false,
message: "Audiobookshelf is not configured",
});
}
const { id } = req.params;
console.log(`\n[AUDIOBOOK PROGRESS] Removing progress:`);
console.log(` User: ${req.user!.username}`);
console.log(` Audiobook ID: ${id}`);
// Delete progress from our database
await prisma.audiobookProgress.deleteMany({
where: {
userId: req.user!.id,
audiobookshelfId: id,
},
});
console.log(` Progress removed from database`);
// Also remove progress from Audiobookshelf
try {
await audiobookshelfService.updateProgress(id, 0, 0, false);
console.log(` Progress reset in Audiobookshelf`);
} catch (error) {
console.error(
"Failed to reset progress in Audiobookshelf:",
error
);
// Continue anyway - local progress is deleted
}
res.json({
success: true,
message: "Progress removed",
});
} catch (error: any) {
console.error("Error removing progress:", error);
res.status(500).json({
error: "Failed to remove progress",
message: error.message,
});
}
}
);
export default router;
+532
View File
@@ -0,0 +1,532 @@
import { Router } from "express";
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 { encrypt, decrypt } from "../utils/encryption";
const router = Router();
const loginSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
// Use shared encryption module for 2FA secrets
const encrypt2FASecret = encrypt;
const decrypt2FASecret = decrypt;
/**
* @openapi
* /auth/login:
* post:
* summary: Login with username and password
* tags: [Authentication]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - username
* - password
* properties:
* username:
* type: string
* password:
* type: string
* format: password
* responses:
* 200:
* description: Login successful
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/User'
* 401:
* description: Invalid credentials
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// POST /auth/login
router.post("/login", async (req, res) => {
try {
const { username, password } = loginSchema.parse(req.body);
const { token } = req.body; // 2FA token if provided
const user = await prisma.user.findUnique({ where: { username } });
if (!user) {
return res.status(401).json({ error: "Invalid credentials" });
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
return res.status(401).json({ error: "Invalid credentials" });
}
// Check if 2FA is enabled
if (user.twoFactorEnabled && user.twoFactorSecret) {
if (!token) {
return res.status(200).json({
requires2FA: true,
message: "2FA token required",
userId: user.id, // Send userId for next 2FA request
});
}
// Check if it's a recovery code
const isRecoveryCode = /^[A-F0-9]{8}$/i.test(token);
if (isRecoveryCode && user.twoFactorRecoveryCodes) {
const encryptedCodes = user.twoFactorRecoveryCodes;
const decryptedCodes = decrypt2FASecret(encryptedCodes);
const hashedCodes = decryptedCodes.split(",");
const providedHash = crypto
.createHash("sha256")
.update(token.toUpperCase())
.digest("hex");
const codeIndex = hashedCodes.indexOf(providedHash);
if (codeIndex === -1) {
return res.status(401).json({ error: "Invalid recovery code" });
}
hashedCodes.splice(codeIndex, 1);
await prisma.user.update({
where: { id: user.id },
data: { twoFactorRecoveryCodes: encrypt2FASecret(hashedCodes.join(",")) },
});
} else {
// Verify TOTP token
const secret = decrypt2FASecret(user.twoFactorSecret);
const verified = speakeasy.totp.verify({
secret,
encoding: "base32",
token,
window: 2,
});
if (!verified) {
return res.status(401).json({ error: "Invalid 2FA token" });
}
}
}
// Generate JWT token
const jwtToken = generateToken({
id: user.id,
username: user.username,
role: user.role,
});
res.json({
token: jwtToken,
user: {
id: user.id,
username: user.username,
role: user.role,
},
});
} catch (err) {
if (err instanceof z.ZodError) {
return res.status(400).json({ error: "Invalid request", details: err.errors });
}
console.error("Login error:", err);
res.status(500).json({ error: "Internal error" });
}
});
// POST /auth/logout - JWT is stateless, logout is handled client-side
router.post("/logout", (req, res) => {
// With JWT, logout is handled by client removing the token
// No server-side session to destroy
res.json({ message: "Logged out" });
});
/**
* @openapi
* /auth/me:
* get:
* summary: Get current authenticated user
* tags: [Authentication]
* security:
* - sessionAuth: []
* responses:
* 200:
* description: Current user information
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/User'
* 401:
* description: Not authenticated
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// GET /auth/me
router.get("/me", requireAuth, async (req, res) => {
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
select: {
id: true,
username: true,
role: true,
onboardingComplete: true,
enrichmentSettings: true,
createdAt: true,
},
});
if (!user) {
return res.status(404).json({ error: "User not found" });
}
res.json(user);
});
// POST /auth/change-password
router.post("/change-password", requireAuth, async (req, res) => {
try {
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) {
return res
.status(400)
.json({ error: "Current and new password are required" });
}
if (newPassword.length < 6) {
return res
.status(400)
.json({ error: "New password must be at least 6 characters" });
}
// Verify current password
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
});
if (!user) {
return res.status(404).json({ error: "User not found" });
}
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
if (!valid) {
return res
.status(401)
.json({ error: "Current password is incorrect" });
}
// Update password
const newPasswordHash = await bcrypt.hash(newPassword, 10);
await prisma.user.update({
where: { id: req.user!.id },
data: { passwordHash: newPasswordHash },
});
res.json({ message: "Password changed successfully" });
} catch (error) {
console.error("Change password error:", error);
res.status(500).json({ error: "Failed to change password" });
}
});
// GET /auth/users (Admin only)
router.get("/users", requireAuth, requireAdmin, async (req, res) => {
try {
const users = await prisma.user.findMany({
select: {
id: true,
username: true,
role: true,
onboardingComplete: true,
createdAt: true,
},
orderBy: { createdAt: "asc" },
});
res.json(users);
} catch (error) {
console.error("Get users error:", error);
res.status(500).json({ error: "Failed to get users" });
}
});
// POST /auth/create-user (Admin only)
router.post("/create-user", requireAuth, requireAdmin, async (req, res) => {
try {
const { username, password, role } = req.body;
if (!username || !password) {
return res
.status(400)
.json({ error: "Username and password are required" });
}
if (password.length < 6) {
return res
.status(400)
.json({ error: "Password must be at least 6 characters" });
}
if (role && !["user", "admin"].includes(role)) {
return res.status(400).json({ error: "Invalid role" });
}
// Check if username exists
const existing = await prisma.user.findUnique({
where: { username },
});
if (existing) {
return res.status(400).json({ error: "Username already taken" });
}
// Create user
const passwordHash = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: {
username,
passwordHash,
role: role || "user",
onboardingComplete: true, // Skip onboarding for created users
},
});
// Create default user settings
await prisma.userSettings.create({
data: {
userId: user.id,
playbackQuality: "original",
wifiOnly: false,
offlineEnabled: false,
maxCacheSizeMb: 10240,
},
});
res.json({
id: user.id,
username: user.username,
role: user.role,
createdAt: user.createdAt,
});
} catch (error) {
console.error("Create user error:", error);
res.status(500).json({ error: "Failed to create user" });
}
});
// DELETE /auth/users/:id (Admin only)
router.delete("/users/:id", requireAuth, requireAdmin, async (req, res) => {
try {
const { id } = req.params;
// Prevent deleting yourself
if (id === req.user!.id) {
return res
.status(400)
.json({ error: "Cannot delete your own account" });
}
// Delete user (cascade will handle related data)
await prisma.user.delete({
where: { id },
});
res.json({ message: "User deleted successfully" });
} catch (error: any) {
console.error("Delete user error:", error);
if (error.code === "P2025") {
return res.status(404).json({ error: "User not found" });
}
res.status(500).json({ error: "Failed to delete user" });
}
});
// POST /auth/2fa/setup - Generate 2FA secret and QR code
router.post("/2fa/setup", requireAuth, async (req, res) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
select: { username: true, twoFactorEnabled: true },
});
if (!user) {
return res.status(404).json({ error: "User not found" });
}
if (user.twoFactorEnabled) {
return res.status(400).json({ error: "2FA is already enabled" });
}
// Generate secret
const secret = speakeasy.generateSecret({
name: `Lidify (${user.username})`,
issuer: "Lidify",
});
// Generate QR code
const qrCodeDataUrl = await QRCode.toDataURL(secret.otpauth_url!);
res.json({
secret: secret.base32,
qrCode: qrCodeDataUrl,
});
} catch (error) {
console.error("2FA setup error:", error);
res.status(500).json({ error: "Failed to setup 2FA" });
}
});
// POST /auth/2fa/enable - Verify token and enable 2FA
router.post("/2fa/enable", requireAuth, async (req, res) => {
try {
const { secret, token } = req.body;
if (!secret || !token) {
return res
.status(400)
.json({ error: "Secret and token are required" });
}
// Verify the token with the secret
const verified = speakeasy.totp.verify({
secret,
encoding: "base32",
token,
window: 2,
});
if (!verified) {
return res
.status(401)
.json({ error: "Invalid token. Please try again." });
}
// Generate 10 recovery codes
const recoveryCodes: string[] = [];
const hashedRecoveryCodes: string[] = [];
for (let i = 0; i < 10; i++) {
// Generate 8-character alphanumeric code
const code = crypto.randomBytes(4).toString("hex").toUpperCase();
recoveryCodes.push(code);
// Hash the code before storing
hashedRecoveryCodes.push(
crypto.createHash("sha256").update(code).digest("hex")
);
}
// Encrypt the hashed codes for storage
const encryptedRecoveryCodes = encrypt2FASecret(
hashedRecoveryCodes.join(",")
);
// Encrypt and save the secret
const encryptedSecret = encrypt2FASecret(secret);
await prisma.user.update({
where: { id: req.user!.id },
data: {
twoFactorEnabled: true,
twoFactorSecret: encryptedSecret,
twoFactorRecoveryCodes: encryptedRecoveryCodes,
},
});
// Return the plain recovery codes to the user (only time they'll see them)
res.json({
message: "2FA enabled successfully",
recoveryCodes: recoveryCodes,
});
} catch (error) {
console.error("2FA enable error:", error);
res.status(500).json({ error: "Failed to enable 2FA" });
}
});
// POST /auth/2fa/disable - Disable 2FA
router.post("/2fa/disable", requireAuth, async (req, res) => {
try {
const { password, token } = req.body;
if (!password || !token) {
return res
.status(400)
.json({ error: "Password and current 2FA token are required" });
}
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
});
if (!user) {
return res.status(404).json({ error: "User not found" });
}
// Verify password
const validPassword = await bcrypt.compare(password, user.passwordHash);
if (!validPassword) {
return res.status(401).json({ error: "Invalid password" });
}
// Verify 2FA token
if (user.twoFactorSecret) {
const secret = decrypt2FASecret(user.twoFactorSecret);
const verified = speakeasy.totp.verify({
secret,
encoding: "base32",
token,
window: 2,
});
if (!verified) {
return res.status(401).json({ error: "Invalid 2FA token" });
}
}
// Disable 2FA
await prisma.user.update({
where: { id: req.user!.id },
data: {
twoFactorEnabled: false,
twoFactorSecret: null,
twoFactorRecoveryCodes: null,
},
});
res.json({ message: "2FA disabled successfully" });
} catch (error) {
console.error("2FA disable error:", error);
res.status(500).json({ error: "Failed to disable 2FA" });
}
});
// GET /auth/2fa/status - Check if 2FA is enabled
router.get("/2fa/status", requireAuth, async (req, res) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
select: { twoFactorEnabled: true },
});
if (!user) {
return res.status(404).json({ error: "User not found" });
}
res.json({ enabled: user.twoFactorEnabled });
} catch (error) {
console.error("2FA status error:", error);
res.status(500).json({ error: "Failed to get 2FA status" });
}
});
export default router;
+377
View File
@@ -0,0 +1,377 @@
import { Router } from "express";
import { requireAuthOrToken } from "../middleware/auth";
import { spotifyService } from "../services/spotify";
import { deezerService, DeezerPlaylistPreview, DeezerRadioStation } from "../services/deezer";
const router = Router();
// All routes require authentication
router.use(requireAuthOrToken);
/**
* Unified playlist preview type
*/
interface PlaylistPreview {
id: string;
source: "deezer" | "spotify";
type: "playlist" | "radio";
title: string;
description: string | null;
creator: string;
imageUrl: string | null;
trackCount: number;
url: string;
}
/**
* Convert Deezer playlist to unified format
*/
function deezerPlaylistToUnified(playlist: DeezerPlaylistPreview): PlaylistPreview {
return {
id: playlist.id,
source: "deezer",
type: "playlist",
title: playlist.title,
description: playlist.description,
creator: playlist.creator,
imageUrl: playlist.imageUrl,
trackCount: playlist.trackCount,
url: `https://www.deezer.com/playlist/${playlist.id}`,
};
}
/**
* Convert Deezer radio to unified format
*/
function deezerRadioToUnified(radio: DeezerRadioStation): PlaylistPreview {
return {
id: radio.id,
source: "deezer",
type: "radio",
title: radio.title,
description: radio.description,
creator: "Deezer",
imageUrl: radio.imageUrl,
trackCount: 0, // Radio tracks are dynamic
url: `https://www.deezer.com/radio-${radio.id}`,
};
}
// ============================================
// Playlist Endpoints
// ============================================
/**
* GET /api/browse/playlists/featured
* Get featured/chart playlists from Deezer
*/
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})...`);
const playlists = await deezerService.getFeaturedPlaylists(limit);
console.log(`[Browse] Got ${playlists.length} Deezer playlists`);
res.json({
playlists: playlists.map(deezerPlaylistToUnified),
total: playlists.length,
source: "deezer",
});
} catch (error: any) {
console.error("Browse featured playlists error:", error);
res.status(500).json({ error: error.message || "Failed to fetch playlists" });
}
});
/**
* GET /api/browse/playlists/search
* Search for playlists on Deezer
*/
router.get("/playlists/search", async (req, res) => {
try {
const query = req.query.q as string;
if (!query || query.length < 2) {
return res.status(400).json({ error: "Search query must be at least 2 characters" });
}
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
console.log(`[Browse] Searching playlists for "${query}"...`);
const playlists = await deezerService.searchPlaylists(query, limit);
console.log(`[Browse] Search "${query}": ${playlists.length} results`);
res.json({
playlists: playlists.map(deezerPlaylistToUnified),
total: playlists.length,
query,
source: "deezer",
});
} catch (error: any) {
console.error("Browse search playlists error:", error);
res.status(500).json({ error: error.message || "Failed to search playlists" });
}
});
/**
* GET /api/browse/playlists/:id
* Get full details of a Deezer playlist
*/
router.get("/playlists/:id", async (req, res) => {
try {
const { id } = req.params;
const playlist = await deezerService.getPlaylist(id);
if (!playlist) {
return res.status(404).json({ error: "Playlist not found" });
}
res.json({
...playlist,
source: "deezer",
url: `https://www.deezer.com/playlist/${id}`,
});
} catch (error: any) {
console.error("Playlist fetch error:", error);
res.status(500).json({ error: error.message || "Failed to fetch playlist" });
}
});
// ============================================
// Radio Endpoints
// ============================================
/**
* GET /api/browse/radios
* Get all radio stations (mood/theme based mixes)
*/
router.get("/radios", async (req, res) => {
try {
console.log("[Browse] Fetching radio stations...");
const radios = await deezerService.getRadioStations();
res.json({
radios: radios.map(deezerRadioToUnified),
total: radios.length,
source: "deezer",
});
} catch (error: any) {
console.error("Browse radios error:", error);
res.status(500).json({ error: error.message || "Failed to fetch radios" });
}
});
/**
* GET /api/browse/radios/by-genre
* Get radio stations organized by genre
*/
router.get("/radios/by-genre", async (req, res) => {
try {
console.log("[Browse] Fetching radios by genre...");
const genresWithRadios = await deezerService.getRadiosByGenre();
// Transform to include unified format
const result = genresWithRadios.map(genre => ({
id: genre.id,
name: genre.name,
radios: genre.radios.map(deezerRadioToUnified),
}));
res.json({
genres: result,
total: result.length,
source: "deezer",
});
} catch (error: any) {
console.error("Browse radios by genre error:", error);
res.status(500).json({ error: error.message || "Failed to fetch radios" });
}
});
/**
* GET /api/browse/radios/:id
* Get tracks from a radio station (as playlist format for import)
*/
router.get("/radios/:id", async (req, res) => {
try {
const { id } = req.params;
console.log(`[Browse] Fetching radio ${id} tracks...`);
const radioPlaylist = await deezerService.getRadioTracks(id);
if (!radioPlaylist) {
return res.status(404).json({ error: "Radio station not found" });
}
res.json({
...radioPlaylist,
source: "deezer",
type: "radio",
});
} catch (error: any) {
console.error("Radio tracks error:", error);
res.status(500).json({ error: error.message || "Failed to fetch radio tracks" });
}
});
// ============================================
// Genre Endpoints
// ============================================
/**
* GET /api/browse/genres
* Get all available genres
*/
router.get("/genres", async (req, res) => {
try {
console.log("[Browse] Fetching genres...");
const genres = await deezerService.getGenres();
res.json({
genres,
total: genres.length,
source: "deezer",
});
} catch (error: any) {
console.error("Browse genres error:", error);
res.status(500).json({ error: error.message || "Failed to fetch genres" });
}
});
/**
* GET /api/browse/genres/:id
* Get content for a specific genre (playlists + radios)
*/
router.get("/genres/:id", async (req, res) => {
try {
const genreId = parseInt(req.params.id);
if (isNaN(genreId)) {
return res.status(400).json({ error: "Invalid genre ID" });
}
console.log(`[Browse] Fetching content for genre ${genreId}...`);
const content = await deezerService.getEditorialContent(genreId);
res.json({
genreId,
playlists: content.playlists.map(deezerPlaylistToUnified),
radios: content.radios.map(deezerRadioToUnified),
source: "deezer",
});
} catch (error: any) {
console.error("Genre content error:", error);
res.status(500).json({ error: error.message || "Failed to fetch genre content" });
}
});
/**
* GET /api/browse/genres/:id/playlists
* Get playlists for a specific genre (by name search)
*/
router.get("/genres/:id/playlists", async (req, res) => {
try {
const genreId = parseInt(req.params.id);
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
// Get genre name first
const genres = await deezerService.getGenres();
const genre = genres.find(g => g.id === genreId);
if (!genre) {
return res.status(404).json({ error: "Genre not found" });
}
const playlists = await deezerService.getGenrePlaylists(genre.name, limit);
res.json({
playlists: playlists.map(deezerPlaylistToUnified),
total: playlists.length,
genre: genre.name,
source: "deezer",
});
} catch (error: any) {
console.error("Genre playlists error:", error);
res.status(500).json({ error: error.message || "Failed to fetch genre playlists" });
}
});
// ============================================
// URL Parsing (supports both Spotify & Deezer)
// ============================================
/**
* POST /api/browse/playlists/parse
* Parse a Spotify or Deezer URL and return playlist info
* This is the main entry point for URL-based imports
*/
router.post("/playlists/parse", async (req, res) => {
try {
const { url } = req.body;
if (!url) {
return res.status(400).json({ error: "URL is required" });
}
// Try Deezer first (our primary source)
const deezerParsed = deezerService.parseUrl(url);
if (deezerParsed && deezerParsed.type === "playlist") {
return res.json({
source: "deezer",
type: "playlist",
id: deezerParsed.id,
url: `https://www.deezer.com/playlist/${deezerParsed.id}`,
});
}
// Try Spotify (still supported for URL imports)
const spotifyParsed = spotifyService.parseUrl(url);
if (spotifyParsed && spotifyParsed.type === "playlist") {
return res.json({
source: "spotify",
type: "playlist",
id: spotifyParsed.id,
url: `https://open.spotify.com/playlist/${spotifyParsed.id}`,
});
}
return res.status(400).json({
error: "Invalid or unsupported URL. Please provide a Spotify or Deezer playlist URL."
});
} catch (error: any) {
console.error("Parse URL error:", error);
res.status(500).json({ error: error.message || "Failed to parse URL" });
}
});
// ============================================
// Combined Browse Endpoint (for frontend convenience)
// ============================================
/**
* GET /api/browse/all
* Get a combined view of featured content (playlists, genres)
* Note: Radio stations are now internal (library-based), not from Deezer
*/
router.get("/all", async (req, res) => {
try {
console.log("[Browse] Fetching browse content (playlists + genres)...");
// Only fetch playlists and genres - radios are now internal library-based
const [playlists, genres] = await Promise.all([
deezerService.getFeaturedPlaylists(200),
deezerService.getGenres(),
]);
res.json({
playlists: playlists.map(deezerPlaylistToUnified),
radios: [], // Radio stations are now internal (use /api/library/radio)
genres,
radiosByGenre: [], // Deprecated - use internal radios
source: "deezer",
});
} catch (error: any) {
console.error("Browse all error:", error);
res.status(500).json({ error: error.message || "Failed to fetch browse content" });
}
});
export default router;
+232
View File
@@ -0,0 +1,232 @@
import { Router } from "express";
import { requireAuthOrToken } from "../middleware/auth";
import { prisma } from "../utils/db";
import crypto from "crypto";
const router = Router();
// Generate a random 6-character alphanumeric code
function generateLinkCode(): string {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // Exclude similar looking chars
let code = "";
for (let i = 0; i < 6; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
return code;
}
// Generate API key
function generateApiKey(): string {
return crypto.randomBytes(32).toString("hex");
}
// POST /device-link/generate - Generate a new device link code (requires auth)
router.post("/generate", requireAuthOrToken, async (req, res) => {
try {
const userId = req.user!.id;
// Delete any existing unused codes for this user
await prisma.deviceLinkCode.deleteMany({
where: {
userId,
usedAt: null,
},
});
// Generate a unique code
let code: string;
let attempts = 0;
do {
code = generateLinkCode();
attempts++;
if (attempts > 10) {
return res.status(500).json({ error: "Failed to generate unique code" });
}
} while (
await prisma.deviceLinkCode.findUnique({
where: { code },
})
);
// Create the code with 5-minute expiry
const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
const linkCode = await prisma.deviceLinkCode.create({
data: {
code,
userId,
expiresAt,
},
});
res.json({
code: linkCode.code,
expiresAt: linkCode.expiresAt,
expiresIn: 300, // 5 minutes in seconds
});
} catch (error) {
console.error("Generate device link code error:", error);
res.status(500).json({ error: "Failed to generate device link code" });
}
});
// POST /device-link/verify - Verify a code and get API key (no auth required)
router.post("/verify", async (req, res) => {
try {
const { code, deviceName } = req.body;
if (!code || typeof code !== "string") {
return res.status(400).json({ error: "Code is required" });
}
// Find the code
const linkCode = await prisma.deviceLinkCode.findUnique({
where: { code: code.toUpperCase() },
include: { user: true },
});
if (!linkCode) {
return res.status(404).json({ error: "Invalid code" });
}
if (linkCode.usedAt) {
return res.status(400).json({ error: "Code already used" });
}
if (new Date() > linkCode.expiresAt) {
return res.status(400).json({ error: "Code expired" });
}
// Generate API key for this device
const apiKey = generateApiKey();
const createdApiKey = await prisma.apiKey.create({
data: {
userId: linkCode.userId,
key: apiKey,
name: deviceName || "Mobile Device",
},
});
// Mark the link code as used
await prisma.deviceLinkCode.update({
where: { id: linkCode.id },
data: {
usedAt: new Date(),
deviceName: deviceName || "Mobile Device",
apiKeyId: createdApiKey.id,
},
});
res.json({
success: true,
apiKey,
userId: linkCode.userId,
username: linkCode.user.username,
});
} catch (error) {
console.error("Verify device link code error:", error);
res.status(500).json({ error: "Failed to verify device link code" });
}
});
// GET /device-link/status/:code - Poll for code usage status (no auth required)
router.get("/status/:code", async (req, res) => {
try {
const { code } = req.params;
const linkCode = await prisma.deviceLinkCode.findUnique({
where: { code: code.toUpperCase() },
});
if (!linkCode) {
return res.status(404).json({ error: "Invalid code" });
}
if (new Date() > linkCode.expiresAt && !linkCode.usedAt) {
return res.json({
status: "expired",
expiresAt: linkCode.expiresAt,
});
}
if (linkCode.usedAt) {
return res.json({
status: "used",
usedAt: linkCode.usedAt,
deviceName: linkCode.deviceName,
});
}
res.json({
status: "pending",
expiresAt: linkCode.expiresAt,
});
} catch (error) {
console.error("Check device link status error:", error);
res.status(500).json({ error: "Failed to check status" });
}
});
// GET /device-link/devices - List linked devices (requires auth)
router.get("/devices", requireAuthOrToken, async (req, res) => {
try {
const userId = req.user!.id;
const apiKeys = await prisma.apiKey.findMany({
where: { userId },
orderBy: { lastUsed: "desc" },
select: {
id: true,
name: true,
lastUsed: true,
createdAt: true,
},
});
res.json(apiKeys);
} catch (error) {
console.error("Get devices error:", error);
res.status(500).json({ error: "Failed to get devices" });
}
});
// DELETE /device-link/devices/:id - Revoke a device (requires auth)
router.delete("/devices/:id", requireAuthOrToken, async (req, res) => {
try {
const userId = req.user!.id;
const { id } = req.params;
const apiKey = await prisma.apiKey.findFirst({
where: { id, userId },
});
if (!apiKey) {
return res.status(404).json({ error: "Device not found" });
}
await prisma.apiKey.delete({
where: { id },
});
res.json({ success: true });
} catch (error) {
console.error("Revoke device error:", error);
res.status(500).json({ error: "Failed to revoke device" });
}
});
export default router;
File diff suppressed because it is too large Load Diff
+588
View File
@@ -0,0 +1,588 @@
import { Router } from "express";
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 { simpleDownloadManager } from "../services/simpleDownloadManager";
import crypto from "crypto";
const router = Router();
router.use(requireAuthOrToken);
// POST /downloads - Create download job
router.post("/", async (req, res) => {
try {
const {
type,
mbid,
subject,
artistName,
albumTitle,
downloadType = "library",
} = req.body;
const userId = req.user!.id;
if (!type || !mbid || !subject) {
return res.status(400).json({
error: "Missing required fields: type, mbid, subject",
});
}
if (type !== "artist" && type !== "album") {
return res
.status(400)
.json({ error: "Type must be 'artist' or 'album'" });
}
if (downloadType !== "library" && downloadType !== "discovery") {
return res.status(400).json({
error: "downloadType must be 'library' or 'discovery'",
});
}
// Check if Lidarr is enabled (database or .env)
const lidarrEnabled = await lidarrService.isEnabled();
if (!lidarrEnabled) {
return res.status(400).json({
error: "Lidarr not configured. Please add albums manually to your library.",
});
}
// Determine root folder path based on download type
const rootFolderPath =
downloadType === "discovery" ? "/music/discovery" : "/music";
if (type === "artist") {
// For artist downloads, fetch albums and create individual jobs
const jobs = await processArtistDownload(
userId,
mbid,
subject,
rootFolderPath,
downloadType
);
return res.json({
id: jobs[0]?.id || null,
status: "processing",
downloadType,
rootFolderPath,
message: `Creating download jobs for ${jobs.length} album(s)...`,
albumCount: jobs.length,
jobs: jobs.map((j) => ({ id: j.id, subject: j.subject })),
});
}
// Single album download - check for existing job first
const existingJob = await prisma.downloadJob.findFirst({
where: {
targetMbid: mbid,
status: { in: ["pending", "processing"] },
},
});
if (existingJob) {
console.log(`[DOWNLOAD] Job already exists for ${mbid}: ${existingJob.id} (${existingJob.status})`);
return res.json({
id: existingJob.id,
status: existingJob.status,
downloadType,
rootFolderPath,
message: "Download already in progress",
duplicate: true,
});
}
const job = await prisma.downloadJob.create({
data: {
userId,
subject,
type,
targetMbid: mbid,
status: "pending",
metadata: {
downloadType,
rootFolderPath,
artistName,
albumTitle,
},
},
});
console.log(
`[DOWNLOAD] Triggering Lidarr: ${type} "${subject}" -> ${rootFolderPath}`
);
// Process in background
processDownload(
job.id,
type,
mbid,
subject,
rootFolderPath,
artistName,
albumTitle
).catch((error) => {
console.error(
`Download processing failed for job ${job.id}:`,
error
);
});
res.json({
id: job.id,
status: job.status,
downloadType,
rootFolderPath,
message: "Download job created. Processing in background.",
});
} catch (error) {
console.error("Create download job error:", error);
res.status(500).json({ error: "Failed to create download job" });
}
});
/**
* Process artist download by creating individual album jobs
*/
async function processArtistDownload(
userId: string,
artistMbid: string,
artistName: string,
rootFolderPath: string,
downloadType: string
): Promise<{ id: string; subject: string }[]> {
console.log(`\n Processing artist download: ${artistName}`);
console.log(` Artist MBID: ${artistMbid}`);
// Generate a batch ID to group all album downloads
const batchId = crypto.randomUUID();
console.log(` Batch ID: ${batchId}`);
try {
// First, add the artist to Lidarr (this monitors all albums)
const lidarrArtist = await lidarrService.addArtist(
artistMbid,
artistName,
rootFolderPath
);
if (!lidarrArtist) {
console.log(` Failed to add artist to Lidarr`);
throw new Error("Failed to add artist to Lidarr");
}
console.log(` Artist added to Lidarr (ID: ${lidarrArtist.id})`);
// Fetch albums from MusicBrainz
const releaseGroups = await musicBrainzService.getReleaseGroups(
artistMbid,
["album", "ep"],
100
);
console.log(
` Found ${releaseGroups.length} albums/EPs from MusicBrainz`
);
if (releaseGroups.length === 0) {
console.log(` No albums found for artist`);
return [];
}
// Create individual album jobs
const jobs: { id: string; subject: string }[] = [];
for (const rg of releaseGroups) {
const albumMbid = rg.id;
const albumTitle = rg.title;
const albumSubject = `${artistName} - ${albumTitle}`;
// Check if we already have this album downloaded
const existingAlbum = await prisma.album.findFirst({
where: { rgMbid: albumMbid },
});
if (existingAlbum) {
console.log(` 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"] },
},
});
if (existingJob) {
console.log(
` Skipping "${albumTitle}" - 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
},
},
});
jobs.push({ id: job.id, subject: albumSubject });
console.log(` [JOB] Created job for: ${albumSubject}`);
// Start the download in background
processDownload(
job.id,
"album",
albumMbid,
albumSubject,
rootFolderPath,
artistName,
albumTitle
).catch((error) => {
console.error(`Download failed for ${albumSubject}:`, error);
});
}
console.log(` Created ${jobs.length} album download jobs`);
return jobs;
} catch (error: any) {
console.error(` Failed to process artist download:`, error.message);
throw error;
}
}
// Background download processor
async function processDownload(
jobId: string,
type: string,
mbid: string,
subject: string,
rootFolderPath: string,
artistName?: string,
albumTitle?: string
) {
const job = await prisma.downloadJob.findUnique({ where: { id: jobId } });
if (!job) {
console.error(`Job ${jobId} not found`);
return;
}
if (type === "album") {
// For albums, use the simple download manager
let parsedArtist = artistName;
let parsedAlbum = albumTitle;
if (!parsedArtist || !parsedAlbum) {
const parts = subject.split(" - ");
if (parts.length >= 2) {
parsedArtist = parts[0].trim();
parsedAlbum = parts.slice(1).join(" - ").trim();
} else {
parsedArtist = subject;
parsedAlbum = subject;
}
}
console.log(`Parsed: Artist="${parsedArtist}", Album="${parsedAlbum}"`);
// Use simple download manager for album downloads
const result = await simpleDownloadManager.startDownload(
jobId,
parsedArtist,
parsedAlbum,
mbid,
job.userId
);
if (!result.success) {
console.error(`Failed to start download: ${result.error}`);
}
}
}
// DELETE /downloads/clear-all - Clear all download jobs for the current user
// IMPORTANT: Must be BEFORE /:id route to avoid catching "clear-all" as an ID
router.delete("/clear-all", async (req, res) => {
try {
const userId = req.user!.id;
const { status } = req.query;
const where: any = { userId };
if (status) {
where.status = status as string;
}
const result = await prisma.downloadJob.deleteMany({ where });
console.log(
` Cleared ${result.count} download jobs for user ${userId}`
);
res.json({ success: true, deleted: result.count });
} catch (error) {
console.error("Clear downloads error:", error);
res.status(500).json({ error: "Failed to clear downloads" });
}
});
// POST /downloads/clear-lidarr-queue - Clear stuck/failed items from Lidarr's queue
router.post("/clear-lidarr-queue", async (req, res) => {
try {
const result = await simpleDownloadManager.clearLidarrQueue();
res.json({
success: true,
removed: result.removed,
errors: result.errors,
});
} catch (error: any) {
console.error("Clear Lidarr queue error:", error);
res.status(500).json({ error: "Failed to clear Lidarr queue" });
}
});
// GET /downloads/failed - List failed/unavailable albums for the current user
// IMPORTANT: Must be BEFORE /:id route to avoid catching "failed" as an ID
router.get("/failed", async (req, res) => {
try {
const userId = req.user!.id;
const failedAlbums = await prisma.unavailableAlbum.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
});
res.json(failedAlbums);
} catch (error) {
console.error("List failed albums error:", error);
res.status(500).json({ error: "Failed to list failed albums" });
}
});
// DELETE /downloads/failed/:id - Dismiss a failed album notification
router.delete("/failed/:id", async (req, res) => {
try {
const { id } = req.params;
const userId = req.user!.id;
// Verify ownership before deleting
const failedAlbum = await prisma.unavailableAlbum.findFirst({
where: { id, userId },
});
if (!failedAlbum) {
return res.status(404).json({ error: "Failed album not found" });
}
await prisma.unavailableAlbum.delete({
where: { id },
});
res.json({ success: true });
} catch (error) {
console.error("Delete failed album error:", error);
res.status(500).json({ error: "Failed to delete failed album" });
}
});
// GET /downloads/:id - Get download job status
router.get("/:id", async (req, res) => {
try {
const { id } = req.params;
const userId = req.user!.id;
const job = await prisma.downloadJob.findFirst({
where: {
id,
userId,
},
});
if (!job) {
return res.status(404).json({ error: "Download job not found" });
}
res.json(job);
} catch (error) {
console.error("Get download job error:", error);
res.status(500).json({ error: "Failed to get download job" });
}
});
// PATCH /downloads/:id - Update download job (e.g., mark as complete)
router.patch("/:id", async (req, res) => {
try {
const { id } = req.params;
const userId = req.user!.id;
const { status } = req.body;
const job = await prisma.downloadJob.findFirst({
where: {
id,
userId,
},
});
if (!job) {
return res.status(404).json({ error: "Download job not found" });
}
const updated = await prisma.downloadJob.update({
where: { id },
data: {
status: status || "completed",
completedAt: status === "completed" ? new Date() : undefined,
},
});
res.json(updated);
} catch (error) {
console.error("Update download job error:", error);
res.status(500).json({ error: "Failed to update download job" });
}
});
// DELETE /downloads/:id - Delete download job
router.delete("/:id", async (req, res) => {
try {
const { id } = req.params;
const userId = req.user!.id;
// Use deleteMany to handle race conditions gracefully
// This won't throw an error if the record was already deleted
const result = await prisma.downloadJob.deleteMany({
where: {
id,
userId,
},
});
// 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);
res.status(500).json({
error: "Failed to delete download job",
details: error.message,
});
}
});
// GET /downloads - List user's download jobs
router.get("/", async (req, res) => {
try {
const userId = req.user!.id;
const { status, limit = "50", includeDiscovery = "false", includeCleared = "false" } = req.query;
const where: any = { userId };
if (status) {
where.status = status as string;
}
// Filter out cleared jobs by default (user dismissed from history)
if (includeCleared !== "true") {
where.cleared = false;
}
const jobs = await prisma.downloadJob.findMany({
where,
orderBy: { createdAt: "desc" },
take: parseInt(limit as string, 10),
});
// Filter out discovery downloads unless explicitly requested
// Discovery downloads are automated and shouldn't show in the UI popover
const filteredJobs =
includeDiscovery === "true"
? jobs
: jobs.filter((job) => {
const metadata = job.metadata as any;
return metadata?.downloadType !== "discovery";
});
res.json(filteredJobs);
} catch (error) {
console.error("List download jobs error:", error);
res.status(500).json({ error: "Failed to list download jobs" });
}
});
// POST /downloads/keep-track - Keep a discovery track (move to permanent library)
router.post("/keep-track", async (req, res) => {
try {
const { discoveryTrackId } = req.body;
const userId = req.user!.id;
if (!discoveryTrackId) {
return res.status(400).json({ error: "Missing discoveryTrackId" });
}
const discoveryTrack = await prisma.discoveryTrack.findUnique({
where: { id: discoveryTrackId },
include: {
discoveryAlbum: true,
},
});
if (!discoveryTrack) {
return res.status(404).json({ error: "Discovery track not found" });
}
// Mark as kept
await prisma.discoveryTrack.update({
where: { id: discoveryTrackId },
data: { userKept: true },
});
// If Lidarr enabled, create job to download full album to permanent library
const lidarrEnabled = await lidarrService.isEnabled();
if (lidarrEnabled) {
const job = await prisma.downloadJob.create({
data: {
userId,
subject: `${discoveryTrack.discoveryAlbum.albumTitle} by ${discoveryTrack.discoveryAlbum.artistName}`,
type: "album",
targetMbid: discoveryTrack.discoveryAlbum.rgMbid,
status: "pending",
},
});
return res.json({
success: true,
message:
"Track marked as kept. Full album will be downloaded to permanent library.",
downloadJobId: job.id,
});
}
res.json({
success: true,
message:
"Track marked as kept. Please add the full album manually to your /music folder.",
});
} catch (error) {
console.error("Keep track error:", error);
res.status(500).json({ error: "Failed to keep track" });
}
});
export default router;
+293
View File
@@ -0,0 +1,293 @@
import { Router } from "express";
import { requireAuth, requireAdmin } from "../middleware/auth";
import { enrichmentService } from "../services/enrichment";
import { getEnrichmentProgress, runFullEnrichment } from "../workers/unifiedEnrichment";
const router = Router();
router.use(requireAuth);
/**
* GET /enrichment/progress
* Get comprehensive enrichment progress (artists, track tags, audio analysis)
*/
router.get("/progress", async (req, res) => {
try {
const progress = await getEnrichmentProgress();
res.json(progress);
} catch (error) {
console.error("Get enrichment progress error:", error);
res.status(500).json({ error: "Failed to get progress" });
}
});
/**
* POST /enrichment/full
* Trigger full enrichment (re-enriches everything regardless of status)
* Admin only
*/
router.post("/full", requireAdmin, async (req, res) => {
try {
// This runs in the background
runFullEnrichment().catch(err => {
console.error("Full enrichment error:", err);
});
res.json({
message: "Full enrichment started",
description: "All artists, track tags, and audio analysis will be re-processed"
});
} catch (error) {
console.error("Trigger full enrichment error:", error);
res.status(500).json({ error: "Failed to start full enrichment" });
}
});
/**
* GET /enrichment/settings
* Get enrichment settings for current user
*/
router.get("/settings", async (req, res) => {
try {
const userId = req.user!.id;
const settings = await enrichmentService.getSettings(userId);
res.json(settings);
} catch (error) {
console.error("Get enrichment settings error:", error);
res.status(500).json({ error: "Failed to get settings" });
}
});
/**
* PUT /enrichment/settings
* Update enrichment settings for current user
*/
router.put("/settings", async (req, res) => {
try {
const userId = req.user!.id;
const settings = await enrichmentService.updateSettings(userId, req.body);
res.json(settings);
} catch (error) {
console.error("Update enrichment settings error:", error);
res.status(500).json({ error: "Failed to update settings" });
}
});
/**
* POST /enrichment/artist/:id
* Enrich a single artist
*/
router.post("/artist/:id", async (req, res) => {
try {
const userId = req.user!.id;
const settings = await enrichmentService.getSettings(userId);
if (!settings.enabled) {
return res.status(400).json({ error: "Enrichment is not enabled" });
}
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);
}
res.json({
success: true,
confidence: enrichmentData.confidence,
data: enrichmentData,
});
} catch (error: any) {
console.error("Enrich artist error:", error);
res.status(500).json({ error: error.message || "Failed to enrich artist" });
}
});
/**
* POST /enrichment/album/:id
* Enrich a single album
*/
router.post("/album/:id", async (req, res) => {
try {
const userId = req.user!.id;
const settings = await enrichmentService.getSettings(userId);
if (!settings.enabled) {
return res.status(400).json({ error: "Enrichment is not enabled" });
}
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);
}
res.json({
success: true,
confidence: enrichmentData.confidence,
data: enrichmentData,
});
} catch (error: any) {
console.error("Enrich album error:", error);
res.status(500).json({ error: error.message || "Failed to enrich album" });
}
});
/**
* POST /enrichment/start
* Start library-wide enrichment (runs in background)
*/
router.post("/start", async (req, res) => {
try {
const userId = req.user!.id;
const { notificationService } = await import("../services/notificationService");
// Check if enrichment is enabled in system settings
const { prisma } = await import("../utils/db");
const systemSettings = await prisma.systemSettings.findUnique({
where: { id: "default" },
select: { autoEnrichMetadata: true },
});
if (!systemSettings?.autoEnrichMetadata) {
return res.status(400).json({ error: "Enrichment is not enabled. Enable it in settings first." });
}
// Get user enrichment settings or use defaults
const settings = await enrichmentService.getSettings(userId);
// Override enabled flag with system setting
settings.enabled = true;
// Send notification that enrichment is starting
await notificationService.notifySystem(
userId,
"Library Enrichment Started",
"Enriching artist metadata in the background..."
);
// 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",
});
});
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" });
}
});
/**
* PUT /library/artists/:id/metadata
* Update artist metadata manually
*/
router.put("/artists/:id/metadata", async (req, res) => {
try {
const { name, bio, genres, mbid, 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);
// Mark as manually edited
updateData.manuallyEdited = true;
const { prisma } = await import("../utils/db");
const artist = await prisma.artist.update({
where: { id: req.params.id },
data: updateData,
include: {
albums: {
select: {
id: true,
title: true,
year: true,
coverUrl: true,
},
},
},
});
res.json(artist);
} catch (error: any) {
console.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
*/
router.put("/albums/:id/metadata", async (req, res) => {
try {
const { title, year, genres, rgMbid, 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);
// Mark as manually edited
updateData.manuallyEdited = true;
const { prisma } = await import("../utils/db");
const album = await prisma.album.update({
where: { id: req.params.id },
data: updateData,
include: {
artist: {
select: {
id: true,
name: true,
},
},
tracks: {
select: {
id: true,
title: true,
trackNo: true,
duration: true,
},
},
},
});
res.json(album);
} catch (error: any) {
console.error("Update album metadata error:", error);
res.status(500).json({ error: error.message || "Failed to update album" });
}
});
export default router;
+187
View File
@@ -0,0 +1,187 @@
import { Router } from "express";
import { requireAuthOrToken } from "../middleware/auth";
import { prisma } from "../utils/db";
import { redisClient } from "../utils/redis";
const router = Router();
// All routes require auth (session or API key)
router.use(requireAuthOrToken);
/**
* GET /homepage/genres
* Get top genres from user's library with sample albums
*/
router.get("/genres", async (req, res) => {
try {
const { limit = "4" } = req.query; // Get top 4 genres by default
const limitNum = parseInt(limit as string, 10);
// Check Redis cache first (cache for 24 hours)
const cacheKey = `homepage:genres:${limitNum}`;
try {
const cached = await redisClient.get(cacheKey);
if (cached) {
console.log(`[HOMEPAGE] Cache HIT for genres`);
return res.json(JSON.parse(cached));
}
} catch (cacheError) {
console.warn("[HOMEPAGE] Redis cache read error:", cacheError);
}
console.log(
`[HOMEPAGE] ✗ Cache MISS for genres, fetching from database...`
);
// Get all albums with genres (excluding discovery albums)
const albums = await prisma.album.findMany({
where: {
genres: {
isEmpty: false, // Only albums with genres
},
location: "LIBRARY", // Exclude discovery albums
},
select: {
id: true,
title: true,
year: true,
coverUrl: true,
genres: true,
artistId: true,
artist: {
select: {
id: true,
name: true,
},
},
},
});
// 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);
}
}
// Get top genres
const topGenres = Array.from(genreCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, limitNum)
.map(([genre]) => genre);
console.log(`[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))
.slice(0, 10)
.map((a) => ({
id: a.id,
title: a.title,
year: a.year,
coverArt: a.coverUrl,
artist: {
id: a.artist.id,
name: a.artist.name,
},
}));
return {
genre,
albums: genreAlbums,
totalCount: genreCounts.get(genre) || 0,
};
});
// Cache for 24 hours
try {
await redisClient.setEx(
cacheKey,
24 * 60 * 60,
JSON.stringify(genresWithAlbums)
);
console.log(`[HOMEPAGE] Cached genres for 24 hours`);
} catch (cacheError) {
console.warn("[HOMEPAGE] Redis cache write error:", cacheError);
}
res.json(genresWithAlbums);
} catch (error) {
console.error("Get homepage genres error:", error);
res.status(500).json({ error: "Failed to fetch genres" });
}
});
/**
* GET /homepage/top-podcasts
* Get top podcasts (most subscribed or most recent episodes)
*/
router.get("/top-podcasts", async (req, res) => {
try {
const { limit = "6" } = req.query; // Get top 6 podcasts by default
const limitNum = parseInt(limit as string, 10);
// Check Redis cache first (cache for 24 hours)
const cacheKey = `homepage:top-podcasts:${limitNum}`;
try {
const cached = await redisClient.get(cacheKey);
if (cached) {
console.log(`[HOMEPAGE] Cache HIT for top podcasts`);
return res.json(JSON.parse(cached));
}
} catch (cacheError) {
console.warn("[HOMEPAGE] Redis cache read error:", cacheError);
}
console.log(
`[HOMEPAGE] ✗ Cache MISS for top podcasts, fetching from database...`
);
// Get podcasts with episode counts
const podcasts = await prisma.podcast.findMany({
take: limitNum,
orderBy: { createdAt: "desc" }, // Most recently added
select: {
id: true,
title: true,
author: true,
description: true,
imageUrl: true,
_count: {
select: { episodes: true },
},
},
});
const result = podcasts.map((podcast) => ({
id: podcast.id,
title: podcast.title,
author: podcast.author,
description: podcast.description?.substring(0, 150) + "...",
coverArt: podcast.imageUrl,
episodeCount: podcast._count.episodes,
}));
// Cache for 24 hours
try {
await redisClient.setEx(
cacheKey,
24 * 60 * 60,
JSON.stringify(result)
);
console.log(`[HOMEPAGE] Cached top podcasts for 24 hours`);
} catch (cacheError) {
console.warn("[HOMEPAGE] Redis cache write error:", cacheError);
}
res.json(result);
} catch (error) {
console.error("Get top podcasts error:", error);
res.status(500).json({ error: "Failed to fetch top podcasts" });
}
});
export default router;
File diff suppressed because it is too large Load Diff
+108
View File
@@ -0,0 +1,108 @@
import { Router } from "express";
import { requireAuth } from "../middleware/auth";
import { prisma } from "../utils/db";
import { z } from "zod";
const router = Router();
router.use(requireAuth);
const listeningStateSchema = z.object({
kind: z.enum(["music", "book"]),
entityId: z.string(),
trackId: z.string().optional(),
positionMs: z.number().int().min(0),
});
// POST /listening-state
router.post("/", async (req, res) => {
try {
const userId = req.session.userId!;
const data = listeningStateSchema.parse(req.body);
const state = await prisma.listeningState.upsert({
where: {
userId_kind_entityId: {
userId,
kind: data.kind,
entityId: data.entityId,
},
},
create: {
userId,
...data,
},
update: {
trackId: data.trackId,
positionMs: data.positionMs,
updatedAt: new Date(),
},
});
res.json(state);
} catch (error) {
if (error instanceof z.ZodError) {
return res
.status(400)
.json({ error: "Invalid request", details: error.errors });
}
console.error("Update listening state error:", error);
res.status(500).json({ error: "Failed to update listening state" });
}
});
// GET /listening-state
router.get("/", async (req, res) => {
try {
const userId = req.session.userId!;
const { kind, entityId } = req.query;
if (!kind || !entityId) {
return res
.status(400)
.json({ error: "kind and entityId required" });
}
const state = await prisma.listeningState.findUnique({
where: {
userId_kind_entityId: {
userId,
kind: kind as string,
entityId: entityId as string,
},
},
});
if (!state) {
return res.status(404).json({ error: "No listening state found" });
}
res.json(state);
} catch (error) {
console.error("Get listening state error:", error);
res.status(500).json({ error: "Failed to get listening state" });
}
});
// GET /listening-state/recent (for "Continue Listening")
router.get("/recent", async (req, res) => {
try {
const userId = req.session.userId!;
const { limit = "10" } = req.query;
const states = await prisma.listeningState.findMany({
where: { userId },
orderBy: { updatedAt: "desc" },
take: parseInt(limit as string, 10),
});
res.json(states);
} catch (error) {
console.error("Get recent listening states error:", error);
res.status(500).json({
error: "Failed to get recent listening states",
});
}
});
export default router;
+684
View File
@@ -0,0 +1,684 @@
import { Router } from "express";
import { requireAuthOrToken } from "../middleware/auth";
import { programmaticPlaylistService } from "../services/programmaticPlaylists";
import { prisma } from "../utils/db";
import { redisClient } from "../utils/redis";
const router = Router();
router.use(requireAuthOrToken);
const getRequestUserId = (req: any): string | null => {
return req.user?.id || req.session?.userId || null;
};
/**
* @openapi
* /mixes:
* get:
* summary: Get all programmatic mixes
* description: Returns all auto-generated mixes (era-based, genre-based, top tracks, rediscover, artist similar, random discovery)
* tags: [Mixes]
* security:
* - sessionAuth: []
* - apiKeyAuth: []
* responses:
* 200:
* description: List of programmatic mixes
* content:
* application/json:
* schema:
* type: array
* items:
* type: object
* properties:
* id:
* type: string
* example: "era-2000"
* type:
* type: string
* enum: [era, genre, top-tracks, rediscover, artist-similar, random-discovery]
* name:
* type: string
* example: "Your 2000s Mix"
* description:
* type: string
* example: "Music from the 2000s in your library"
* trackIds:
* type: array
* items:
* type: string
* coverUrls:
* type: array
* items:
* type: string
* description: Album covers for mosaic display (up to 4)
* trackCount:
* type: integer
* example: 42
* 401:
* description: Not authenticated
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
router.get("/", async (req, res) => {
try {
const userId = getRequestUserId(req);
if (!userId) {
return res.status(401).json({ error: "Not authenticated" });
}
// Check cache first (mixes are expensive to compute)
const cacheKey = `mixes:${userId}`;
const cached = await redisClient.get(cacheKey);
if (cached) {
return res.json(JSON.parse(cached));
}
// Generate all mixes
const mixes = await programmaticPlaylistService.generateAllMixes(
userId
);
// Cache for 1 hour
await redisClient.setEx(cacheKey, 3600, JSON.stringify(mixes));
res.json(mixes);
} catch (error) {
console.error("Get mixes error:", error);
res.status(500).json({ error: "Failed to get mixes" });
}
});
/**
* @openapi
* /mixes/mood:
* post:
* summary: Generate a custom mood-based mix on demand
* description: Creates a personalized mix based on audio features like valence, energy, tempo, etc.
* tags: [Mixes]
* security:
* - sessionAuth: []
* - apiKeyAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* valence:
* type: object
* properties:
* min:
* type: number
* minimum: 0
* maximum: 1
* max:
* type: number
* minimum: 0
* maximum: 1
* energy:
* type: object
* properties:
* min:
* type: number
* max:
* type: number
* danceability:
* type: object
* properties:
* min:
* type: number
* max:
* type: number
* acousticness:
* type: object
* properties:
* min:
* type: number
* max:
* type: number
* instrumentalness:
* type: object
* properties:
* min:
* type: number
* max:
* type: number
* bpm:
* type: object
* properties:
* min:
* type: number
* max:
* type: number
* keyScale:
* type: string
* enum: [major, minor]
* limit:
* type: integer
* default: 15
* responses:
* 200:
* description: Generated mood mix with full track details
* 400:
* description: Not enough tracks matching criteria
* 401:
* description: Not authenticated
*/
router.post("/mood", async (req, res) => {
try {
const userId = getRequestUserId(req);
if (!userId) {
return res.status(401).json({ error: "Not authenticated" });
}
const params = req.body;
// Validate parameters
const validKeys = [
// Basic audio features
'valence', 'energy', 'danceability', 'acousticness', 'instrumentalness', 'arousal', 'bpm', 'keyScale',
// ML mood predictions
'moodHappy', 'moodSad', 'moodRelaxed', 'moodAggressive', 'moodParty', 'moodAcoustic', 'moodElectronic',
// Other
'limit'
];
for (const key of Object.keys(params)) {
if (!validKeys.includes(key)) {
return res.status(400).json({ error: `Invalid parameter: ${key}` });
}
}
const mix = await programmaticPlaylistService.generateMoodOnDemand(userId, params);
if (!mix) {
return res.status(400).json({
error: "Not enough tracks matching your criteria",
suggestion: "Try widening your parameters or wait for more tracks to be analyzed"
});
}
// Load full track details
const tracks = await prisma.track.findMany({
where: {
id: { in: mix.trackIds },
},
include: {
album: {
include: {
artist: {
select: {
id: true,
name: true,
mbid: true,
},
},
},
},
},
});
// Preserve mix order
const orderedTracks = mix.trackIds
.map((id: string) => tracks.find((t) => t.id === id))
.filter((t: any) => t !== undefined);
console.log(`[MIXES] Generated mood-on-demand mix with ${mix.trackCount} tracks`);
res.json({
...mix,
tracks: orderedTracks,
});
} catch (error) {
console.error("Generate mood mix error:", error);
res.status(500).json({ error: "Failed to generate mood mix" });
}
});
/**
* Available mood presets for the UI
*/
router.get("/mood/presets", async (req, res) => {
// Presets use ML mood predictions for more accurate matching
// These mirror the logic used in programmatic mixes (Chill Mix, Party Mix, etc.)
const presets = [
{
id: "happy",
name: "Happy & Upbeat",
color: "from-yellow-400 to-orange-500",
params: { moodHappy: { min: 0.5 }, moodSad: { max: 0.4 }, energy: { min: 0.4 } },
},
{
id: "sad",
name: "Melancholic",
color: "from-blue-600 to-indigo-700",
params: { moodSad: { min: 0.5 }, moodHappy: { max: 0.4 }, keyScale: "minor" },
},
{
id: "chill",
name: "Chill & Relaxed",
color: "from-teal-400 to-cyan-500",
params: { moodRelaxed: { min: 0.5 }, moodAggressive: { max: 0.3 }, energy: { max: 0.55 } },
},
{
id: "energetic",
name: "High Energy",
color: "from-red-500 to-orange-600",
params: { arousal: { min: 0.6 }, energy: { min: 0.65 }, moodRelaxed: { max: 0.4 } },
},
{
id: "focus",
name: "Focus Mode",
color: "from-purple-600 to-violet-700",
params: { instrumentalness: { min: 0.5 }, moodRelaxed: { min: 0.3 }, energy: { min: 0.2, max: 0.6 } },
},
{
id: "dance",
name: "Dance Party",
color: "from-pink-500 to-rose-600",
params: { moodParty: { min: 0.5 }, danceability: { min: 0.6 }, energy: { min: 0.5 } },
},
{
id: "acoustic",
name: "Acoustic Vibes",
color: "from-amber-500 to-yellow-600",
params: { moodAcoustic: { min: 0.5 }, moodElectronic: { max: 0.4 } },
},
{
id: "dark",
name: "Dark & Moody",
color: "from-gray-700 to-slate-800",
params: { moodAggressive: { min: 0.4 }, moodHappy: { max: 0.4 }, keyScale: "minor" },
},
{
id: "romantic",
name: "Romantic",
color: "from-rose-500 to-pink-600",
params: { moodRelaxed: { min: 0.3 }, moodAggressive: { max: 0.3 }, acousticness: { min: 0.3 }, energy: { max: 0.6 } },
},
{
id: "workout",
name: "Workout Beast",
color: "from-green-500 to-emerald-600",
params: { arousal: { min: 0.6 }, energy: { min: 0.7 }, moodRelaxed: { max: 0.4 }, bpm: { min: 110 } },
},
{
id: "sleepy",
name: "Sleep & Unwind",
color: "from-indigo-400 to-purple-500",
params: { moodRelaxed: { min: 0.5 }, energy: { max: 0.35 }, moodAggressive: { max: 0.2 } },
},
{
id: "confident",
name: "Confidence Boost",
color: "from-amber-400 to-orange-500",
params: { moodHappy: { min: 0.4 }, moodParty: { min: 0.3 }, energy: { min: 0.5 }, danceability: { min: 0.5 } },
},
];
res.json(presets);
});
/**
* Save user's mood mix preferences
* These preferences are used to generate "Your Mood Mix" in the mix rotation
*/
router.post("/mood/save-preferences", async (req, res) => {
try {
const userId = getRequestUserId(req);
if (!userId) {
return res.status(401).json({ error: "Not authenticated" });
}
const params = req.body;
// Validate that at least some params are provided
if (!params || Object.keys(params).length === 0) {
return res.status(400).json({ error: "No mood parameters provided" });
}
// Save to user record
await prisma.user.update({
where: { id: userId },
data: { moodMixParams: params }
});
// Invalidate mix cache so the new mood mix appears
const cacheKey = `mixes:${userId}`;
await redisClient.del(cacheKey);
console.log(`[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);
res.status(500).json({ error: "Failed to save mood preferences" });
}
});
/**
* @openapi
* /mixes/refresh:
* post:
* summary: Force refresh all mixes
* description: Clears cache and regenerates all programmatic mixes
* tags: [Mixes]
* security:
* - sessionAuth: []
* - apiKeyAuth: []
* responses:
* 200:
* description: Mixes refreshed successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: "Mixes refreshed"
* mixes:
* type: array
* items:
* type: object
* 401:
* description: Not authenticated
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
router.post("/refresh", async (req, res) => {
try {
const userId = getRequestUserId(req);
if (!userId) {
return res.status(401).json({ error: "Not authenticated" });
}
// Clear cache
const cacheKey = `mixes:${userId}`;
await redisClient.del(cacheKey);
// Regenerate mixes with random selection (not date-based)
const mixes = await programmaticPlaylistService.generateAllMixes(
userId,
true
);
// Cache for 1 hour
await redisClient.setEx(cacheKey, 3600, JSON.stringify(mixes));
res.json({ message: "Mixes refreshed", mixes });
} catch (error) {
console.error("Refresh mixes error:", error);
res.status(500).json({ error: "Failed to refresh mixes" });
}
});
/**
* @openapi
* /mixes/{id}/save:
* post:
* summary: Save a mix as a playlist
* description: Creates a new playlist with all tracks from the specified mix
* tags: [Mixes]
* security:
* - sessionAuth: []
* - apiKeyAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* description: Mix ID to save as playlist
* requestBody:
* required: false
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* description: Optional custom name for the playlist (defaults to mix name)
* responses:
* 200:
* description: Playlist created successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* id:
* type: string
* name:
* type: string
* trackCount:
* type: integer
* 404:
* description: Mix not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Not authenticated
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
router.post("/:id/save", async (req, res) => {
try {
const userId = getRequestUserId(req);
if (!userId) {
return res.status(401).json({ error: "Not authenticated" });
}
const mixId = req.params.id;
const customName = req.body.name;
// Get the mix with track details
const cacheKey = `mixes:${userId}`;
let mixes;
const cached = await redisClient.get(cacheKey);
if (cached) {
mixes = JSON.parse(cached);
} else {
mixes = await programmaticPlaylistService.generateAllMixes(userId);
await redisClient.setEx(cacheKey, 3600, JSON.stringify(mixes));
}
const mix = mixes.find((m: any) => m.id === mixId);
if (!mix) {
return res.status(404).json({ error: "Mix not found" });
}
const existingPlaylist = await prisma.playlist.findFirst({
where: {
userId,
mixId: mix.id,
},
select: {
id: true,
name: true,
},
});
if (existingPlaylist) {
return res.status(409).json({
error: "Mix already saved as playlist",
playlistId: existingPlaylist.id,
name: existingPlaylist.name,
});
}
// Create playlist
const playlist = await prisma.playlist.create({
data: {
userId,
mixId: mix.id,
name: customName || mix.name,
isPublic: false,
},
});
// Add all tracks to the playlist
const playlistItems = mix.trackIds.map(
(trackId: string, index: number) => ({
playlistId: playlist.id,
trackId,
sort: index,
})
);
await prisma.playlistItem.createMany({
data: playlistItems,
});
console.log(
`[MIXES] Saved mix ${mixId} as playlist ${playlist.id} (${mix.trackIds.length} tracks)`
);
res.json({
id: playlist.id,
name: playlist.name,
trackCount: mix.trackIds.length,
});
} catch (error) {
console.error("Save mix as playlist error:", error);
res.status(500).json({ error: "Failed to save mix as playlist" });
}
});
/**
* @openapi
* /mixes/{id}:
* get:
* summary: Get a specific mix with full track details
* tags: [Mixes]
* security:
* - sessionAuth: []
* - apiKeyAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* description: Mix ID (e.g., "era-2000", "genre-rock", "top-tracks")
* responses:
* 200:
* description: Mix with full track details
* content:
* application/json:
* schema:
* type: object
* properties:
* id:
* type: string
* type:
* type: string
* name:
* type: string
* description:
* type: string
* trackIds:
* type: array
* items:
* type: string
* coverUrls:
* type: array
* items:
* type: string
* trackCount:
* type: integer
* tracks:
* type: array
* items:
* $ref: '#/components/schemas/Track'
* 404:
* description: Mix not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Not authenticated
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
router.get("/:id", async (req, res) => {
try {
const userId = getRequestUserId(req);
if (!userId) {
return res.status(401).json({ error: "Not authenticated" });
}
const mixId = req.params.id;
// Get all mixes (from cache if available)
const cacheKey = `mixes:${userId}`;
let mixes;
const cached = await redisClient.get(cacheKey);
if (cached) {
mixes = JSON.parse(cached);
} else {
mixes = await programmaticPlaylistService.generateAllMixes(userId);
await redisClient.setEx(cacheKey, 3600, JSON.stringify(mixes));
}
// Find the specific mix
const mix = mixes.find((m: any) => m.id === mixId);
if (!mix) {
return res.status(404).json({ error: "Mix not found" });
}
// Load full track details
const tracks = await prisma.track.findMany({
where: {
id: {
in: mix.trackIds,
},
},
include: {
album: {
include: {
artist: {
select: {
id: true,
name: true,
mbid: true,
},
},
},
},
},
});
// Preserve mix order
const orderedTracks = mix.trackIds
.map((id: string) => tracks.find((t) => t.id === id))
.filter((t: any) => t !== undefined);
res.json({
...mix,
tracks: orderedTracks,
});
} catch (error) {
console.error("Get mix error:", error);
res.status(500).json({ error: "Failed to get mix" });
}
});
export default router;
+711
View File
@@ -0,0 +1,711 @@
import { Router, Response } from "express";
import { notificationService } from "../services/notificationService";
import { AuthenticatedRequest, requireAuth } from "../middleware/auth";
import { prisma } from "../utils/db";
const router = Router();
/**
* GET /notifications
* Get all uncleared notifications for the current user
*/
router.get(
"/",
requireAuth,
async (req: AuthenticatedRequest, res: Response) => {
try {
console.log(
`[Notifications] Fetching notifications for user ${
req.user!.id
}`
);
const notifications = await notificationService.getForUser(
req.user!.id
);
console.log(
`[Notifications] Found ${notifications.length} notifications`
);
res.json(notifications);
} catch (error: any) {
console.error("Error fetching notifications:", error);
res.status(500).json({ error: "Failed to fetch notifications" });
}
}
);
/**
* GET /notifications/unread-count
* Get count of unread notifications
*/
router.get(
"/unread-count",
requireAuth,
async (req: AuthenticatedRequest, res: Response) => {
try {
const count = await notificationService.getUnreadCount(
req.user!.id
);
res.json({ count });
} catch (error: any) {
console.error("Error fetching unread count:", error);
res.status(500).json({ error: "Failed to fetch unread count" });
}
}
);
/**
* POST /notifications/:id/read
* Mark a notification as read
*/
router.post(
"/:id/read",
requireAuth,
async (req: AuthenticatedRequest, 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);
res.status(500).json({
error: "Failed to mark notification as read",
});
}
}
);
/**
* POST /notifications/read-all
* Mark all notifications as read
*/
router.post(
"/read-all",
requireAuth,
async (req: AuthenticatedRequest, 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);
res.status(500).json({
error: "Failed to mark all notifications as read",
});
}
}
);
/**
* POST /notifications/:id/clear
* Clear (dismiss) a notification
*/
router.post(
"/:id/clear",
requireAuth,
async (req: AuthenticatedRequest, 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);
res.status(500).json({ error: "Failed to clear notification" });
}
}
);
/**
* POST /notifications/clear-all
* Clear all notifications
*/
router.post(
"/clear-all",
requireAuth,
async (req: AuthenticatedRequest, res: Response) => {
try {
await notificationService.clearAll(req.user!.id);
res.json({ success: true });
} catch (error: any) {
console.error("Error clearing all notifications:", error);
res.status(500).json({
error: "Failed to clear all notifications",
});
}
}
);
// ============================================
// Download History Endpoints
// ============================================
/**
* GET /notifications/downloads/history
* Get completed/failed downloads that haven't been cleared
*/
router.get(
"/downloads/history",
requireAuth,
async (req: AuthenticatedRequest, res: Response) => {
try {
const downloads = await prisma.downloadJob.findMany({
where: {
userId: req.user!.id,
status: { in: ["completed", "failed", "exhausted"] },
cleared: false,
},
orderBy: { updatedAt: "desc" },
take: 50,
});
res.json(downloads);
} catch (error: any) {
console.error("Error fetching download history:", error);
res.status(500).json({ error: "Failed to fetch download history" });
}
}
);
/**
* GET /notifications/downloads/active
* Get active downloads (pending/processing)
*/
router.get(
"/downloads/active",
requireAuth,
async (req: AuthenticatedRequest, res: Response) => {
try {
const downloads = await prisma.downloadJob.findMany({
where: {
userId: req.user!.id,
status: { in: ["pending", "processing"] },
},
orderBy: { createdAt: "desc" },
});
res.json(downloads);
} catch (error: any) {
console.error("Error fetching active downloads:", error);
res.status(500).json({ error: "Failed to fetch active downloads" });
}
}
);
/**
* POST /notifications/downloads/:id/clear
* Clear a download from history
*/
router.post(
"/downloads/:id/clear",
requireAuth,
async (req: AuthenticatedRequest, res: Response) => {
try {
await prisma.downloadJob.updateMany({
where: {
id: req.params.id,
userId: req.user!.id,
},
data: { cleared: true },
});
res.json({ success: true });
} catch (error: any) {
console.error("Error clearing download:", error);
res.status(500).json({ error: "Failed to clear download" });
}
}
);
/**
* POST /notifications/downloads/clear-all
* Clear all completed/failed downloads from history
*/
router.post(
"/downloads/clear-all",
requireAuth,
async (req: AuthenticatedRequest, res: Response) => {
try {
await prisma.downloadJob.updateMany({
where: {
userId: req.user!.id,
status: { in: ["completed", "failed", "exhausted"] },
cleared: false,
},
data: { cleared: true },
});
res.json({ success: true });
} catch (error: any) {
console.error("Error clearing all downloads:", error);
res.status(500).json({ error: "Failed to clear all downloads" });
}
}
);
/**
* POST /notifications/downloads/:id/retry
* Retry a failed download
*/
router.post(
"/downloads/:id/retry",
requireAuth,
async (req: AuthenticatedRequest, res: Response) => {
try {
// Get the failed download
const failedJob = await prisma.downloadJob.findFirst({
where: {
id: req.params.id,
userId: req.user!.id,
status: { in: ["failed", "exhausted"] },
},
});
if (!failedJob) {
return res
.status(404)
.json({ error: "Download not found or not failed" });
}
// If this was a pending-track retry job, re-run the pending-track retry flow
const metadata = failedJob.metadata as Record<
string,
unknown
> | null;
if (metadata?.downloadType === "pending-track-retry") {
const playlistId = metadata.playlistId as string | undefined;
const pendingTrackId = metadata.pendingTrackId as
| string
| undefined;
if (!playlistId || !pendingTrackId) {
return res.status(400).json({
error: "Cannot retry: missing playlistId or pendingTrackId",
});
}
// Mark old job as cleared
await prisma.downloadJob.update({
where: { id: failedJob.id },
data: { cleared: true },
});
// Validate playlist ownership and pending track exists
const playlist = await prisma.playlist.findUnique({
where: { id: playlistId },
});
if (!playlist || playlist.userId !== req.user!.id) {
return res
.status(404)
.json({ error: "Playlist not found" });
}
const pendingTrack =
await prisma.playlistPendingTrack.findUnique({
where: { id: pendingTrackId },
});
if (!pendingTrack) {
return res
.status(404)
.json({ error: "Pending track not found" });
}
const retryTargetId =
pendingTrack.albumMbid ||
pendingTrack.artistMbid ||
`pendingTrack:${pendingTrack.id}`;
const newJobRecord = await prisma.downloadJob.create({
data: {
userId: req.user!.id,
subject: `${pendingTrack.spotifyArtist} - ${pendingTrack.spotifyTitle}`,
type: "track",
targetMbid: retryTargetId,
artistMbid: pendingTrack.artistMbid,
status: "processing",
attempts: 1,
startedAt: new Date(),
metadata: {
downloadType: "pending-track-retry",
source: "soulseek",
playlistId,
pendingTrackId,
spotifyArtist: pendingTrack.spotifyArtist,
spotifyTitle: pendingTrack.spotifyTitle,
spotifyAlbum: pendingTrack.spotifyAlbum,
albumMbid: pendingTrack.albumMbid,
},
},
});
const { soulseekService } = await import(
"../services/soulseek"
);
const { getSystemSettings } = await import(
"../utils/systemSettings"
);
const settings = await getSystemSettings();
if (!settings?.musicPath) {
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "failed",
error: "Music path not configured",
completedAt: new Date(),
},
});
return res.json({
success: false,
newJobId: newJobRecord.id,
error: "Music path not configured",
});
}
if (
!settings?.soulseekUsername ||
!settings?.soulseekPassword
) {
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "failed",
error: "Soulseek credentials not configured",
completedAt: new Date(),
},
});
return res.json({
success: false,
newJobId: newJobRecord.id,
error: "Soulseek credentials not configured",
});
}
const albumName =
pendingTrack.spotifyAlbum !== "Unknown Album"
? pendingTrack.spotifyAlbum
: pendingTrack.spotifyArtist;
const searchResult = await soulseekService.searchTrack(
pendingTrack.spotifyArtist,
pendingTrack.spotifyTitle
);
if (
!searchResult.found ||
searchResult.allMatches.length === 0
) {
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "failed",
error: "No matching files found",
completedAt: new Date(),
},
});
return res.json({
success: false,
newJobId: newJobRecord.id,
error: "No matching files found",
});
}
// Start download in background (don't await)
soulseekService
.downloadBestMatch(
pendingTrack.spotifyArtist,
pendingTrack.spotifyTitle,
albumName,
searchResult.allMatches,
settings.musicPath
)
.then(async (result) => {
if (result.success) {
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "completed",
completedAt: new Date(),
metadata: {
...(newJobRecord.metadata as any),
filePath: result.filePath,
},
},
});
try {
const { scanQueue } = await import(
"../workers/queues"
);
await scanQueue.add(
"scan",
{
userId: req.user!.id,
source: "retry-pending-track",
albumMbid:
pendingTrack.albumMbid || undefined,
artistMbid:
pendingTrack.artistMbid ||
undefined,
},
{
priority: 1,
removeOnComplete: true,
}
);
} catch {
// Best-effort; job status already reflects download
}
} else {
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "failed",
error: result.error || "Download failed",
completedAt: new Date(),
},
});
}
})
.catch(async (error) => {
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "failed",
error: error?.message || "Download exception",
completedAt: new Date(),
},
});
});
return res.json({ success: true, newJobId: newJobRecord.id });
}
// If this was a spotify_import job, retry with Soulseek first
if (metadata?.downloadType === "spotify_import") {
const artistName = metadata.artistName as string;
const albumTitle = metadata.albumTitle as string;
if (!artistName || !albumTitle) {
return res
.status(400)
.json({
error: "Cannot retry: missing artist/album info",
});
}
// Mark old job as cleared
await prisma.downloadJob.update({
where: { id: failedJob.id },
data: { cleared: true },
});
// Create a NEW download job record for the retry
const newJobRecord = await prisma.downloadJob.create({
data: {
userId: req.user!.id,
type: "album",
targetMbid:
failedJob.targetMbid || `retry_${Date.now()}`,
artistMbid: failedJob.artistMbid,
subject: `${artistName} - ${albumTitle}`,
status: "processing",
attempts: 1,
startedAt: new Date(),
metadata: {
...metadata,
retryAttempt: true,
},
},
});
// Try Soulseek first (async)
const { soulseekService } = await import(
"../services/soulseek"
);
const { getSystemSettings } = await import(
"../utils/systemSettings"
);
const settings = await getSystemSettings();
const musicPath = settings?.musicPath;
if (!musicPath) {
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "failed",
error: "Music path not configured",
completedAt: new Date(),
},
});
return res.json({
success: false,
newJobId: newJobRecord.id,
error: "Music path not configured",
});
}
// Build track from album info (single track search using album as title)
const tracks = [
{
artist: artistName,
title: albumTitle,
album: albumTitle,
},
];
console.log(
`[Retry] Trying Soulseek for ${artistName} - ${albumTitle}`
);
// Run Soulseek search async
soulseekService
.searchAndDownloadBatch(tracks, musicPath, 4)
.then(async (result) => {
if (result.successful > 0) {
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "completed",
completedAt: new Date(),
error: null,
metadata: {
...metadata,
source: "soulseek",
tracksDownloaded: result.successful,
files: result.files,
},
},
});
console.log(
`[Retry] ✓ Soulseek downloaded ${result.successful} tracks for ${artistName} - ${albumTitle}`
);
// Trigger library scan
const { scanQueue } = await import(
"../workers/queues"
);
await scanQueue.add("scan", {
paths: [],
fullScan: false,
userId: req.user!.id,
source: "retry-spotify-import",
});
} else {
// Soulseek failed, try Lidarr if we have an MBID
console.log(
`[Retry] Soulseek failed, trying Lidarr for ${artistName} - ${albumTitle}`
);
if (
failedJob.targetMbid &&
!failedJob.targetMbid.startsWith("retry_")
) {
const { simpleDownloadManager } = await import(
"../services/simpleDownloadManager"
);
const lidarrResult =
await simpleDownloadManager.startDownload(
newJobRecord.id,
artistName,
albumTitle,
failedJob.targetMbid,
req.user!.id,
false
);
if (!lidarrResult.success) {
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "failed",
error:
lidarrResult.error ||
"Both Soulseek and Lidarr failed",
completedAt: new Date(),
},
});
}
} else {
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "failed",
error: "No tracks found on Soulseek, no MBID for Lidarr fallback",
completedAt: new Date(),
},
});
}
}
})
.catch(async (error) => {
console.error(`[Retry] Soulseek error:`, error);
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "failed",
error: error?.message || "Soulseek error",
completedAt: new Date(),
},
});
});
return res.json({ success: true, newJobId: newJobRecord.id });
}
// Validate that we have the required MBIDs
if (!failedJob.targetMbid) {
return res
.status(400)
.json({ error: "Cannot retry: missing album MBID" });
}
// Mark old job as cleared
await prisma.downloadJob.update({
where: { id: failedJob.id },
data: { cleared: true },
});
// Extract parameters from the failed job
// Subject is typically "Artist - Album" format
const subjectParts = failedJob.subject.split(" - ");
const artistName = subjectParts[0] || failedJob.subject;
const albumTitle =
(metadata?.albumTitle as string) ||
subjectParts[1] ||
failedJob.subject;
// Create a NEW download job record for the retry
const newJobRecord = await prisma.downloadJob.create({
data: {
userId: req.user!.id,
type: failedJob.type as "artist" | "album",
targetMbid: failedJob.targetMbid,
artistMbid: failedJob.artistMbid,
subject: failedJob.subject,
status: "pending",
metadata: metadata || {},
},
});
// Import the download manager dynamically to avoid circular deps
const { simpleDownloadManager } = await import(
"../services/simpleDownloadManager"
);
// Start download with the correct positional arguments
// startDownload(jobId, artistName, albumTitle, albumMbid, userId, isDiscovery)
const result = await simpleDownloadManager.startDownload(
newJobRecord.id,
artistName,
albumTitle,
failedJob.targetMbid,
req.user!.id,
false // isDiscovery
);
res.json({
success: result.success,
newJobId: newJobRecord.id,
error: result.error,
});
} catch (error: any) {
console.error("Error retrying download:", error);
res.status(500).json({ error: "Failed to retry download" });
}
}
);
export default router;
+286
View File
@@ -0,0 +1,286 @@
import { Router } from "express";
import { requireAuth } from "../middleware/auth";
import { prisma } from "../utils/db";
import { z } from "zod";
const router = Router();
router.use(requireAuth);
const downloadAlbumSchema = z.object({
quality: z.enum(["original", "high", "medium", "low"]).optional(),
});
// POST /offline/albums/:id/download
router.post("/albums/:id/download", async (req, res) => {
try {
const userId = req.session.userId!;
const albumId = req.params.id;
const { quality } = downloadAlbumSchema.parse(req.body);
// Get user's default quality if not specified
let selectedQuality = quality;
if (!selectedQuality) {
const settings = await prisma.userSettings.findUnique({
where: { userId },
});
selectedQuality = (settings?.playbackQuality as any) || "medium";
}
// Get album with tracks
const album = await prisma.album.findUnique({
where: { id: albumId },
include: {
tracks: {
orderBy: { trackNo: "asc" },
},
artist: {
select: {
name: true,
},
},
},
});
if (!album) {
return res.status(404).json({ error: "Album not found" });
}
// Calculate total size estimate
const avgSizeMb: Record<string, number> = {
original: 30, // FLAC
high: 10, // MP3 320
medium: 6, // MP3 192
low: 4, // MP3 128
};
const estimatedSizeMb =
album.tracks.length * avgSizeMb[selectedQuality];
// Check user's cache limit
const settings = await prisma.userSettings.findUnique({
where: { userId },
});
if (settings) {
const currentCacheSize = await prisma.cachedTrack.aggregate({
where: { userId },
_sum: { fileSizeMb: true },
});
const currentSize = currentCacheSize._sum.fileSizeMb || 0;
if (currentSize + estimatedSizeMb > settings.maxCacheSizeMb) {
return res.status(400).json({
error: "Cache size limit exceeded",
currentSize,
maxSize: settings.maxCacheSizeMb,
needed: estimatedSizeMb,
});
}
}
// Create download job (tracks to be downloaded by mobile client)
const downloadJob = {
albumId: album.id,
albumTitle: album.title,
artistName: album.artist.name,
quality: selectedQuality,
tracks: album.tracks.map((track) => ({
trackId: track.id,
title: track.title,
trackNo: track.trackNo,
duration: track.duration,
streamUrl: `/library/tracks/${track.id}/stream?quality=${selectedQuality}`,
})),
estimatedSizeMb,
};
res.json(downloadJob);
} catch (error) {
if (error instanceof z.ZodError) {
return res
.status(400)
.json({ error: "Invalid request", details: error.errors });
}
console.error("Create download job error:", error);
res.status(500).json({ error: "Failed to create download job" });
}
});
// POST /offline/tracks/:id/complete (called by mobile after download)
router.post("/tracks/:id/complete", async (req, res) => {
try {
const userId = req.session.userId!;
const trackId = req.params.id;
const { localPath, quality, fileSizeMb } = req.body;
if (!localPath || !quality || !fileSizeMb) {
return res
.status(400)
.json({ error: "localPath, quality, and fileSizeMb required" });
}
const cachedTrack = await prisma.cachedTrack.upsert({
where: {
userId_trackId_quality: {
userId,
trackId,
quality,
},
},
create: {
userId,
trackId,
localPath,
quality,
fileSizeMb: parseFloat(fileSizeMb),
},
update: {
localPath,
fileSizeMb: parseFloat(fileSizeMb),
lastAccessedAt: new Date(),
},
});
res.json(cachedTrack);
} catch (error) {
console.error("Complete track download error:", error);
res.status(500).json({ error: "Failed to complete download" });
}
});
// GET /offline/albums
router.get("/albums", async (req, res) => {
try {
const userId = req.session.userId!;
// Get all cached tracks grouped by album
const cachedTracks = await prisma.cachedTrack.findMany({
where: { userId },
include: {
track: {
include: {
album: {
include: {
artist: {
select: {
id: true,
name: true,
mbid: true,
},
},
},
},
},
},
},
});
// Group by album
const albumsMap = new Map();
for (const cached of cachedTracks) {
const albumId = cached.track.album.id;
if (!albumsMap.has(albumId)) {
albumsMap.set(albumId, {
album: cached.track.album,
tracks: [],
totalSizeMb: 0,
});
}
const albumData = albumsMap.get(albumId);
albumData.tracks.push({
...cached.track,
cachedPath: cached.localPath,
cachedQuality: cached.quality,
cachedSizeMb: cached.fileSizeMb,
});
albumData.totalSizeMb += cached.fileSizeMb;
}
const albums = Array.from(albumsMap.values()).map((data) => ({
...data.album,
cachedTracks: data.tracks,
totalSizeMb: data.totalSizeMb,
}));
res.json(albums);
} catch (error) {
console.error("Get cached albums error:", error);
res.status(500).json({ error: "Failed to get cached albums" });
}
});
// DELETE /offline/albums/:id
router.delete("/albums/:id", async (req, res) => {
try {
const userId = req.session.userId!;
const albumId = req.params.id;
// Get all cached tracks for this album
const cachedTracks = await prisma.cachedTrack.findMany({
where: {
userId,
track: {
albumId,
},
},
});
// Delete all cached tracks for this album
await prisma.cachedTrack.deleteMany({
where: {
userId,
track: {
albumId,
},
},
});
res.json({
message: "Album removed from cache",
deletedCount: cachedTracks.length,
});
} catch (error) {
console.error("Delete cached album error:", error);
res.status(500).json({ error: "Failed to delete cached album" });
}
});
// GET /offline/stats
router.get("/stats", async (req, res) => {
try {
const userId = req.session.userId!;
const [settings, cacheStats] = await Promise.all([
prisma.userSettings.findUnique({
where: { userId },
}),
prisma.cachedTrack.aggregate({
where: { userId },
_sum: { fileSizeMb: true },
_count: true,
}),
]);
const usedMb = cacheStats._sum.fileSizeMb || 0;
const maxMb = settings?.maxCacheSizeMb || 5120;
const trackCount = cacheStats._count || 0;
res.json({
usedMb,
maxMb,
availableMb: maxMb - usedMb,
percentUsed: (usedMb / maxMb) * 100,
trackCount,
});
} catch (error) {
console.error("Get cache stats error:", error);
res.status(500).json({ error: "Failed to get cache stats" });
}
});
export default router;
+475
View File
@@ -0,0 +1,475 @@
import { Router } from "express";
import { prisma } from "../utils/db";
import bcrypt from "bcrypt";
import { z } from "zod";
import axios from "axios";
import crypto from "crypto";
import { encryptField } from "../utils/systemSettings";
import { writeEnvFile } from "../utils/envWriter";
import { generateToken, requireAuth } from "../middleware/auth";
const router = Router();
// Validation schemas
const registerSchema = z.object({
username: z.string().min(3).max(50),
password: z.string().min(6),
});
const lidarrConfigSchema = z.object({
url: z.string().url().optional().or(z.literal("")),
apiKey: z.string().optional().or(z.literal("")),
enabled: z.boolean(),
});
const audiobookshelfConfigSchema = z.object({
url: z.string().url().optional().or(z.literal("")),
apiKey: z.string().optional().or(z.literal("")),
enabled: z.boolean(),
});
const soulseekConfigSchema = z.object({
username: z.string().optional().or(z.literal("")),
password: z.string().optional().or(z.literal("")),
enabled: z.boolean(),
});
const enrichmentConfigSchema = z.object({
enabled: z.boolean(),
});
/**
* Generate a secure encryption key for settings encryption
* This is called automatically during first user registration
*/
async function ensureEncryptionKey(): Promise<void> {
// Check if encryption key already exists
if (
process.env.SETTINGS_ENCRYPTION_KEY &&
process.env.SETTINGS_ENCRYPTION_KEY !==
"default-encryption-key-change-me"
) {
console.log("[ONBOARDING] Encryption key already exists");
return;
}
// Generate a secure 32-byte encryption key
const encryptionKey = crypto.randomBytes(32).toString("base64");
console.log(
"[ONBOARDING] Generating encryption key for settings security..."
);
try {
// Write to .env file
await writeEnvFile({
SETTINGS_ENCRYPTION_KEY: encryptionKey,
});
// 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");
} catch (error) {
console.error("[ONBOARDING] ✗ Failed to save encryption key:", error);
throw new Error("Failed to generate encryption key");
}
}
/**
* POST /onboarding/register
* Step 1: Create user account - returns JWT token like regular login
*/
router.post("/register", async (req, res) => {
try {
console.log("[ONBOARDING] Register attempt for user:", req.body?.username);
const { username, password } = registerSchema.parse(req.body);
// Check if any user exists (first user becomes admin)
const userCount = await prisma.user.count();
const isFirstUser = userCount === 0;
// If this is the first user, ensure encryption key is generated
if (isFirstUser) {
await ensureEncryptionKey();
}
// Check if username is taken
const existing = await prisma.user.findUnique({
where: { username },
});
if (existing) {
console.log("[ONBOARDING] Username already taken:", username);
return res.status(400).json({ error: "Username already taken" });
}
// Create user
const passwordHash = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: {
username,
passwordHash,
role: isFirstUser ? "admin" : "user",
onboardingComplete: false,
},
});
// Create default user settings with optimal defaults
await prisma.userSettings.create({
data: {
userId: user.id,
playbackQuality: "original",
wifiOnly: false,
offlineEnabled: false,
maxCacheSizeMb: 10240, // 10GB
},
});
// Generate JWT token (same as login)
const token = generateToken({
id: user.id,
username: user.username,
role: user.role,
});
console.log("[ONBOARDING] User created successfully:", user.username);
res.json({
token,
user: {
id: user.id,
username: user.username,
role: user.role,
onboardingComplete: false,
},
});
} catch (err: any) {
if (err instanceof z.ZodError) {
console.error("[ONBOARDING] Validation error:", err.errors);
return res
.status(400)
.json({ error: "Invalid request", details: err.errors });
}
console.error("Registration error:", err);
res.status(500).json({ error: "Failed to create account" });
}
});
/**
* POST /onboarding/lidarr
* Step 2a: Configure Lidarr integration
*/
router.post("/lidarr", requireAuth, async (req, res) => {
try {
const config = lidarrConfigSchema.parse(req.body);
// If not enabled, just save as disabled
if (!config.enabled) {
const settings = await prisma.systemSettings.findFirst();
if (settings) {
await prisma.systemSettings.update({
where: { id: settings.id },
data: { lidarrEnabled: false },
});
}
return res.json({ success: true, tested: false });
}
// Test connection if enabled (non-blocking - save anyway)
let connectionTested = false;
if (config.url && config.apiKey) {
try {
const response = await axios.get(
`${config.url}/api/v1/system/status`,
{
headers: { "X-Api-Key": config.apiKey },
timeout: 5000,
}
);
if (response.status === 200) {
connectionTested = true;
console.log("Lidarr connection test successful");
}
} catch (error: any) {
console.warn(
" Lidarr connection test failed (saved anyway):",
error.message
);
// Don't block - just log the warning
}
}
// Save to system settings (even if connection test failed)
await prisma.systemSettings.upsert({
where: { id: "default" },
create: {
id: "default",
lidarrEnabled: config.enabled,
lidarrUrl: config.url || null,
lidarrApiKey: encryptField(config.apiKey),
},
update: {
lidarrEnabled: config.enabled,
lidarrUrl: config.url || null,
lidarrApiKey: encryptField(config.apiKey),
},
});
res.json({
success: true,
tested: connectionTested,
warning: connectionTested
? null
: "Connection test failed but settings saved. You can test again in Settings.",
});
} catch (err: any) {
if (err instanceof z.ZodError) {
return res
.status(400)
.json({ error: "Invalid request", details: err.errors });
}
console.error("Lidarr config error:", err);
res.status(500).json({ error: "Failed to save configuration" });
}
});
/**
* POST /onboarding/audiobookshelf
* Step 2b: Configure Audiobookshelf integration
*/
router.post("/audiobookshelf", requireAuth, async (req, res) => {
try {
const config = audiobookshelfConfigSchema.parse(req.body);
// If not enabled, just save as disabled
if (!config.enabled) {
const settings = await prisma.systemSettings.findFirst();
if (settings) {
await prisma.systemSettings.update({
where: { id: settings.id },
data: { audiobookshelfEnabled: false },
});
}
return res.json({ success: true, tested: false });
}
// Test connection if enabled (non-blocking - save anyway)
let connectionTested = false;
if (config.url && config.apiKey) {
try {
const response = await axios.get(`${config.url}/api/me`, {
headers: { Authorization: `Bearer ${config.apiKey}` },
timeout: 5000,
});
if (response.status === 200) {
connectionTested = true;
console.log("Audiobookshelf connection test successful");
}
} catch (error: any) {
console.warn(
" Audiobookshelf connection test failed (saved anyway):",
error.message
);
// Don't block - just log the warning
}
}
// Save to system settings (even if connection test failed)
await prisma.systemSettings.upsert({
where: { id: "default" },
create: {
id: "default",
audiobookshelfEnabled: config.enabled,
audiobookshelfUrl: config.url || null,
audiobookshelfApiKey: encryptField(config.apiKey),
},
update: {
audiobookshelfEnabled: config.enabled,
audiobookshelfUrl: config.url || null,
audiobookshelfApiKey: encryptField(config.apiKey),
},
});
res.json({
success: true,
tested: connectionTested,
warning: connectionTested
? null
: "Connection test failed but settings saved. You can test again in Settings.",
});
} catch (err: any) {
if (err instanceof z.ZodError) {
return res
.status(400)
.json({ error: "Invalid request", details: err.errors });
}
console.error("Audiobookshelf config error:", err);
res.status(500).json({ error: "Failed to save configuration" });
}
});
/**
* POST /onboarding/soulseek
* Step 2c: Configure Soulseek integration (direct connection via slsk-client)
*/
router.post("/soulseek", requireAuth, async (req, res) => {
try {
const config = soulseekConfigSchema.parse(req.body);
// If not enabled, clear credentials
if (!config.enabled) {
await prisma.systemSettings.upsert({
where: { id: "default" },
create: {
id: "default",
soulseekUsername: null,
soulseekPassword: null,
},
update: {
soulseekUsername: null,
soulseekPassword: null,
},
});
return res.json({ success: true, tested: false });
}
// If enabled, require credentials
if (!config.username || !config.password) {
return res.status(400).json({
error: "Soulseek username and password are required",
});
}
// Save to system settings
await prisma.systemSettings.upsert({
where: { id: "default" },
create: {
id: "default",
soulseekUsername: config.username,
soulseekPassword: encryptField(config.password),
},
update: {
soulseekUsername: config.username,
soulseekPassword: encryptField(config.password),
},
});
res.json({ success: true, tested: true });
} catch (err: any) {
if (err instanceof z.ZodError) {
return res
.status(400)
.json({ error: "Invalid request", details: err.errors });
}
console.error("Soulseek config error:", err);
res.status(500).json({ error: "Failed to save configuration" });
}
});
/**
* POST /onboarding/enrichment
* Step 3: Configure metadata enrichment
*/
router.post("/enrichment", requireAuth, async (req, res) => {
try {
const config = enrichmentConfigSchema.parse(req.body);
// Update user settings
await prisma.user.update({
where: { id: req.user!.id },
data: {
enrichmentSettings: {
enabled: config.enabled,
lastRun: null,
},
},
});
res.json({ success: true });
} catch (err: any) {
if (err instanceof z.ZodError) {
return res
.status(400)
.json({ error: "Invalid request", details: err.errors });
}
console.error("Enrichment config error:", err);
res.status(500).json({ error: "Failed to save configuration" });
}
});
/**
* POST /onboarding/complete
* Final step: Mark onboarding as complete
*/
router.post("/complete", requireAuth, async (req, res) => {
try {
await prisma.user.update({
where: { id: req.user!.id },
data: { onboardingComplete: true },
});
console.log("[ONBOARDING] User completed onboarding:", req.user!.id);
res.json({ success: true });
} catch (err: any) {
console.error("Onboarding complete error:", err);
res.status(500).json({ error: "Failed to complete onboarding" });
}
});
/**
* GET /onboarding/status
* Check if user needs onboarding
*/
router.get("/status", async (req, res) => {
try {
// Check if any users exist in the system
const userCount = await prisma.user.count();
const hasAccount = userCount > 0;
// Check for JWT token in Authorization header
const authHeader = req.headers.authorization;
const token = authHeader?.startsWith("Bearer ")
? authHeader.substring(7)
: null;
// If no token, return whether any users exist
if (!token) {
return res.json({
needsOnboarding: !hasAccount,
hasAccount,
});
}
// Try to verify token and check onboarding status
try {
const jwt = require("jsonwebtoken");
const JWT_SECRET =
process.env.JWT_SECRET ||
"your-secret-key-change-in-production";
const decoded = jwt.verify(token, JWT_SECRET) as { userId: string };
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: { onboardingComplete: true },
});
res.json({
needsOnboarding: !user?.onboardingComplete,
hasAccount: true,
});
} catch {
// Invalid token - return basic status
res.json({
needsOnboarding: !hasAccount,
hasAccount,
});
}
} catch (err: any) {
console.error("Onboarding status error:", err);
res.status(500).json({ error: "Failed to check status" });
}
});
export default router;
+149
View File
@@ -0,0 +1,149 @@
import express from "express";
import { prisma } from "../utils/db";
import { requireAuth } from "../middleware/auth";
const router = express.Router();
// Get current playback state for the authenticated user
router.get("/", requireAuth, async (req, res) => {
try {
const userId = req.user!.id;
const playbackState = await prisma.playbackState.findUnique({
where: { userId },
});
if (!playbackState) {
return res.json(null);
}
res.json(playbackState);
} catch (error) {
console.error("Get playback state error:", error);
res.status(500).json({ error: "Failed to get playback state" });
}
});
// Update current playback state for the authenticated user
router.post("/", requireAuth, async (req, res) => {
try {
const userId = req.user!.id;
const {
playbackType,
trackId,
audiobookId,
podcastId,
queue,
currentIndex,
isShuffle,
} = req.body;
// Validate required field
if (!playbackType) {
return res.status(400).json({ error: "playbackType is required" });
}
// Validate playback type
const validPlaybackTypes = ["track", "audiobook", "podcast"];
if (!validPlaybackTypes.includes(playbackType)) {
console.warn(`[PlaybackState] Invalid playbackType: ${playbackType}`);
return res.status(400).json({ error: "Invalid playbackType" });
}
// Limit queue size and sanitize queue items to prevent database issues
let safeQueue: any[] | null = null;
if (Array.isArray(queue) && queue.length > 0) {
// Only keep essential fields from each queue item to reduce JSON size
// Filter out any invalid items first
try {
safeQueue = queue
.slice(0, 100)
.filter((item: any) => item && item.id) // Must have at least an ID
.map((item: any) => ({
id: String(item.id || ""),
title: String(item.title || "Unknown").substring(0, 500), // Limit title length
duration: Number(item.duration) || 0,
artist: item.artist ? {
id: String(item.artist.id || ""),
name: String(item.artist.name || "Unknown").substring(0, 200),
} : null,
album: item.album ? {
id: String(item.album.id || ""),
title: String(item.album.title || "Unknown").substring(0, 500),
coverArt: item.album.coverArt ? String(item.album.coverArt).substring(0, 1000) : null,
} : null,
}));
// If sanitization removed all items, set to null
if (safeQueue.length === 0) {
safeQueue = null;
}
} catch (sanitizeError: any) {
console.error("[PlaybackState] Queue sanitization failed:", sanitizeError?.message);
safeQueue = null; // Fall back to null queue
}
}
const safeCurrentIndex = Math.min(
Math.max(0, currentIndex || 0),
safeQueue?.length ? safeQueue.length - 1 : 0
);
const playbackState = await prisma.playbackState.upsert({
where: { userId },
update: {
playbackType,
trackId: trackId || null,
audiobookId: audiobookId || null,
podcastId: podcastId || null,
queue: safeQueue,
currentIndex: safeCurrentIndex,
isShuffle: isShuffle || false,
},
create: {
userId,
playbackType,
trackId: trackId || null,
audiobookId: audiobookId || null,
podcastId: podcastId || null,
queue: safeQueue,
currentIndex: safeCurrentIndex,
isShuffle: isShuffle || false,
},
});
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));
if (error?.code) {
console.error("[PlaybackState] Error code:", error.code);
}
if (error?.meta) {
console.error("[PlaybackState] Prisma meta:", error.meta);
}
// Return more specific error for debugging
res.status(500).json({
error: "Internal server error",
details: error?.message || "Unknown error"
});
}
});
// Clear playback state (when user stops playback completely)
router.delete("/", requireAuth, async (req, res) => {
try {
const userId = req.user!.id;
await prisma.playbackState.delete({
where: { userId },
});
res.json({ success: true });
} catch (error) {
console.error("Delete playback state error:", error);
res.status(500).json({ error: "Failed to delete playback state" });
}
});
export default router;
+985
View File
@@ -0,0 +1,985 @@
import { Router } from "express";
import { requireAuthOrToken } from "../middleware/auth";
import { prisma } from "../utils/db";
import { z } from "zod";
import { sessionLog } from "../utils/playlistLogger";
const router = Router();
router.use(requireAuthOrToken);
const createPlaylistSchema = z.object({
name: z.string().min(1).max(200),
isPublic: z.boolean().optional().default(false),
});
const addTrackSchema = z.object({
trackId: z.string(),
});
// GET /playlists
router.get("/", async (req, res) => {
try {
const userId = req.user.id;
// Get user's hidden playlists
const hiddenPlaylists = await prisma.hiddenPlaylist.findMany({
where: { userId },
select: { playlistId: true },
});
const hiddenPlaylistIds = new Set(
hiddenPlaylists.map((h) => h.playlistId)
);
const playlists = await prisma.playlist.findMany({
where: {
OR: [{ userId }, { isPublic: true }],
},
orderBy: { createdAt: "desc" },
include: {
user: {
select: {
username: true,
},
},
items: {
include: {
track: {
include: {
album: {
include: {
artist: {
select: {
id: true,
name: true,
},
},
},
},
},
},
},
orderBy: { sort: "asc" },
},
},
});
const playlistsWithCounts = playlists.map((playlist) => ({
...playlist,
trackCount: playlist.items.length,
isOwner: playlist.userId === userId,
isHidden: hiddenPlaylistIds.has(playlist.id),
}));
// Debug: log shared playlists with user info
const sharedPlaylists = playlistsWithCounts.filter((p) => !p.isOwner);
if (sharedPlaylists.length > 0) {
console.log(
`[Playlists] Found ${sharedPlaylists.length} shared playlists for user ${userId}:`
);
sharedPlaylists.forEach((p) => {
console.log(
` - "${p.name}" by ${
p.user?.username || "UNKNOWN"
} (owner: ${p.userId})`
);
});
}
res.json(playlistsWithCounts);
} catch (error) {
console.error("Get playlists error:", error);
res.status(500).json({ error: "Failed to get playlists" });
}
});
// POST /playlists
router.post("/", async (req, res) => {
try {
const userId = req.user.id;
const data = createPlaylistSchema.parse(req.body);
const playlist = await prisma.playlist.create({
data: {
userId,
name: data.name,
isPublic: data.isPublic,
},
});
res.json(playlist);
} catch (error) {
if (error instanceof z.ZodError) {
return res
.status(400)
.json({ error: "Invalid request", details: error.errors });
}
console.error("Create playlist error:", error);
res.status(500).json({ error: "Failed to create playlist" });
}
});
// GET /playlists/:id
router.get("/:id", async (req, res) => {
try {
const userId = req.user.id;
const playlist = await prisma.playlist.findUnique({
where: { id: req.params.id },
include: {
user: {
select: {
username: true,
},
},
items: {
include: {
track: {
include: {
album: {
include: {
artist: {
select: {
id: true,
name: true,
mbid: true,
},
},
},
},
},
},
},
orderBy: { sort: "asc" },
},
pendingTracks: {
orderBy: { sort: "asc" },
},
},
});
if (!playlist) {
return res.status(404).json({ error: "Playlist not found" });
}
// Check access permissions
if (!playlist.isPublic && playlist.userId !== userId) {
return res.status(403).json({ error: "Access denied" });
}
// Format playlist items
const formattedItems = playlist.items.map((item) => ({
...item,
type: "track" as const,
track: {
...item.track,
album: {
...item.track.album,
coverArt: item.track.album.coverUrl,
},
},
}));
// Format pending tracks
const formattedPending = playlist.pendingTracks.map((pending) => ({
id: pending.id,
type: "pending" as const,
sort: pending.sort,
pending: {
id: pending.id,
artist: pending.spotifyArtist,
title: pending.spotifyTitle,
album: pending.spotifyAlbum,
previewUrl: pending.deezerPreviewUrl,
},
}));
// Merge and sort by position
const mergedItems = [
...formattedItems.map((item) => ({ ...item, sort: item.sort })),
...formattedPending,
].sort((a, b) => a.sort - b.sort);
res.json({
...playlist,
isOwner: playlist.userId === userId,
trackCount: playlist.items.length,
pendingCount: playlist.pendingTracks.length,
items: formattedItems,
pendingTracks: formattedPending,
mergedItems,
});
} catch (error) {
console.error("Get playlist error:", error);
res.status(500).json({ error: "Failed to get playlist" });
}
});
// PUT /playlists/:id
router.put("/:id", async (req, res) => {
try {
const userId = req.user.id;
const data = createPlaylistSchema.parse(req.body);
// Check ownership
const existing = await prisma.playlist.findUnique({
where: { id: req.params.id },
});
if (!existing) {
return res.status(404).json({ error: "Playlist not found" });
}
if (existing.userId !== userId) {
return res.status(403).json({ error: "Access denied" });
}
const playlist = await prisma.playlist.update({
where: { id: req.params.id },
data: {
name: data.name,
isPublic: data.isPublic,
},
});
res.json(playlist);
} catch (error) {
if (error instanceof z.ZodError) {
return res
.status(400)
.json({ error: "Invalid request", details: error.errors });
}
console.error("Update playlist error:", error);
res.status(500).json({ error: "Failed to update playlist" });
}
});
// POST /playlists/:id/hide - Hide any playlist from your view
router.post("/:id/hide", async (req, res) => {
try {
const userId = req.user.id;
const playlistId = req.params.id;
// Check playlist exists
const playlist = await prisma.playlist.findUnique({
where: { id: playlistId },
});
if (!playlist) {
return res.status(404).json({ error: "Playlist not found" });
}
// User must own the playlist OR it must be public (shared)
if (playlist.userId !== userId && !playlist.isPublic) {
return res.status(403).json({ error: "Access denied" });
}
// Create hidden record (upsert to handle re-hiding)
await prisma.hiddenPlaylist.upsert({
where: {
userId_playlistId: { userId, playlistId },
},
create: { userId, playlistId },
update: {},
});
res.json({ message: "Playlist hidden", isHidden: true });
} catch (error) {
console.error("Hide playlist error:", error);
res.status(500).json({ error: "Failed to hide playlist" });
}
});
// DELETE /playlists/:id/hide - Unhide a shared playlist
router.delete("/:id/hide", async (req, res) => {
try {
const userId = req.user.id;
const playlistId = req.params.id;
// Delete hidden record if exists
await prisma.hiddenPlaylist.deleteMany({
where: { userId, playlistId },
});
res.json({ message: "Playlist unhidden", isHidden: false });
} catch (error) {
console.error("Unhide playlist error:", error);
res.status(500).json({ error: "Failed to unhide playlist" });
}
});
// DELETE /playlists/:id
router.delete("/:id", async (req, res) => {
try {
const userId = req.user.id;
// Check ownership
const existing = await prisma.playlist.findUnique({
where: { id: req.params.id },
});
if (!existing) {
return res.status(404).json({ error: "Playlist not found" });
}
if (existing.userId !== userId) {
return res.status(403).json({ error: "Access denied" });
}
await prisma.playlist.delete({
where: { id: req.params.id },
});
res.json({ message: "Playlist deleted" });
} catch (error) {
console.error("Delete playlist error:", error);
res.status(500).json({ error: "Failed to delete playlist" });
}
});
// POST /playlists/:id/items
router.post("/:id/items", async (req, res) => {
try {
const userId = req.user.id;
const parsedBody = addTrackSchema.safeParse(req.body);
if (!parsedBody.success) {
return res.status(400).json({
error: "Invalid request",
details: parsedBody.error.errors,
});
}
const { trackId } = parsedBody.data;
// Check ownership
const playlist = await prisma.playlist.findUnique({
where: { id: req.params.id },
include: {
items: {
orderBy: { sort: "desc" },
take: 1,
},
},
});
if (!playlist) {
return res.status(404).json({ error: "Playlist not found" });
}
if (playlist.userId !== userId) {
return res.status(403).json({ error: "Access denied" });
}
// Check if track exists
const track = await prisma.track.findUnique({
where: { id: trackId },
});
if (!track) {
return res.status(404).json({ error: "Track not found" });
}
// Check if track already in playlist
const existing = await prisma.playlistItem.findUnique({
where: {
playlistId_trackId: {
playlistId: req.params.id,
trackId,
},
},
});
if (existing) {
return res.status(200).json({
message: "Track already in playlist",
duplicated: true,
item: existing,
});
}
// Get next sort position
const maxSort = playlist.items[0]?.sort || 0;
const item = await prisma.playlistItem.create({
data: {
playlistId: req.params.id,
trackId,
sort: maxSort + 1,
},
include: {
track: {
include: {
album: {
include: {
artist: true,
},
},
},
},
},
});
res.json(item);
} catch (error) {
if (error instanceof z.ZodError) {
return res
.status(400)
.json({ error: "Invalid request", details: error.errors });
}
console.error("Add track to playlist error:", error);
res.status(500).json({ error: "Failed to add track to playlist" });
}
});
// DELETE /playlists/:id/items/:trackId
router.delete("/:id/items/:trackId", async (req, res) => {
try {
const userId = req.user.id;
// Check ownership
const playlist = await prisma.playlist.findUnique({
where: { id: req.params.id },
});
if (!playlist) {
return res.status(404).json({ error: "Playlist not found" });
}
if (playlist.userId !== userId) {
return res.status(403).json({ error: "Access denied" });
}
await prisma.playlistItem.delete({
where: {
playlistId_trackId: {
playlistId: req.params.id,
trackId: req.params.trackId,
},
},
});
res.json({ message: "Track removed from playlist" });
} catch (error) {
console.error("Remove track from playlist error:", error);
res.status(500).json({ error: "Failed to remove track from playlist" });
}
});
// PUT /playlists/:id/items/reorder
router.put("/:id/items/reorder", async (req, res) => {
try {
const userId = req.user.id;
const { trackIds } = req.body; // Array of track IDs in new order
if (!Array.isArray(trackIds)) {
return res.status(400).json({ error: "trackIds must be an array" });
}
// Check ownership
const playlist = await prisma.playlist.findUnique({
where: { id: req.params.id },
});
if (!playlist) {
return res.status(404).json({ error: "Playlist not found" });
}
if (playlist.userId !== userId) {
return res.status(403).json({ error: "Access denied" });
}
// Update sort order for each track
const updates = trackIds.map((trackId, index) =>
prisma.playlistItem.update({
where: {
playlistId_trackId: {
playlistId: req.params.id,
trackId,
},
},
data: { sort: index },
})
);
await prisma.$transaction(updates);
res.json({ message: "Playlist reordered" });
} catch (error) {
console.error("Reorder playlist error:", error);
res.status(500).json({ error: "Failed to reorder playlist" });
}
});
// ============================================
// Pending Tracks (from Spotify imports)
// ============================================
/**
* GET /playlists/:id/pending
* Get pending tracks for a playlist (tracks from Spotify that haven't been matched yet)
*/
router.get("/:id/pending", async (req, res) => {
try {
const userId = req.user.id;
const playlistId = req.params.id;
// Check ownership or public access
const playlist = await prisma.playlist.findUnique({
where: { id: playlistId },
});
if (!playlist) {
return res.status(404).json({ error: "Playlist not found" });
}
if (playlist.userId !== userId && !playlist.isPublic) {
return res.status(403).json({ error: "Access denied" });
}
const pendingTracks = await prisma.playlistPendingTrack.findMany({
where: { playlistId },
orderBy: { sort: "asc" },
});
res.json({
count: pendingTracks.length,
tracks: pendingTracks.map((t) => ({
id: t.id,
artist: t.spotifyArtist,
title: t.spotifyTitle,
album: t.spotifyAlbum,
position: t.sort,
previewUrl: t.deezerPreviewUrl,
})),
spotifyPlaylistId: playlist.spotifyPlaylistId,
});
} catch (error) {
console.error("Get pending tracks error:", error);
res.status(500).json({ error: "Failed to get pending tracks" });
}
});
/**
* DELETE /playlists/:id/pending/:trackId
* Remove a pending track (user decides they don't want to wait for it)
*/
router.delete("/:id/pending/:trackId", async (req, res) => {
try {
const userId = req.user.id;
const { id: playlistId, trackId: pendingTrackId } = req.params;
// Check ownership
const playlist = await prisma.playlist.findUnique({
where: { id: playlistId },
});
if (!playlist) {
return res.status(404).json({ error: "Playlist not found" });
}
if (playlist.userId !== userId) {
return res.status(403).json({ error: "Access denied" });
}
await prisma.playlistPendingTrack.delete({
where: { id: pendingTrackId },
});
res.json({ message: "Pending track removed" });
} catch (error: any) {
if (error.code === "P2025") {
return res.status(404).json({ error: "Pending track not found" });
}
console.error("Delete pending track error:", error);
res.status(500).json({ error: "Failed to delete pending track" });
}
});
/**
* GET /playlists/:id/pending/:trackId/preview
* Get a fresh Deezer preview URL for a pending track (since they expire)
*/
router.get("/:id/pending/:trackId/preview", async (req, res) => {
try {
const { trackId: pendingTrackId } = req.params;
// Get the pending track
const pendingTrack = await prisma.playlistPendingTrack.findUnique({
where: { id: pendingTrackId },
});
if (!pendingTrack) {
return res.status(404).json({ error: "Pending track not found" });
}
// Fetch fresh Deezer preview URL
const { deezerService } = await import("../services/deezer");
const previewUrl = await deezerService.getTrackPreview(
pendingTrack.spotifyArtist,
pendingTrack.spotifyTitle
);
if (!previewUrl) {
return res
.status(404)
.json({ error: "No preview available on Deezer" });
}
// Update the stored preview URL for future use
await prisma.playlistPendingTrack.update({
where: { id: pendingTrackId },
data: { deezerPreviewUrl: previewUrl },
});
res.json({ previewUrl });
} catch (error: any) {
console.error("Get preview URL error:", error);
res.status(500).json({ error: "Failed to get preview URL" });
}
});
/**
* POST /playlists/:id/pending/:trackId/retry
* Retry downloading a failed/pending track from Soulseek
* Returns immediately and downloads in background
*/
router.post("/:id/pending/:trackId/retry", async (req, res) => {
try {
const userId = req.user.id;
const { id: playlistId, trackId: pendingTrackId } = req.params;
sessionLog(
"PENDING-RETRY",
`Request: userId=${userId} playlistId=${playlistId} pendingTrackId=${pendingTrackId}`
);
// Check ownership
const playlist = await prisma.playlist.findUnique({
where: { id: playlistId },
});
if (!playlist) {
sessionLog(
"PENDING-RETRY",
`Playlist not found: ${playlistId}`,
"WARN"
);
return res.status(404).json({ error: "Playlist not found" });
}
if (playlist.userId !== userId) {
sessionLog(
"PENDING-RETRY",
`Access denied: playlistId=${playlistId} userId=${userId}`,
"WARN"
);
return res.status(403).json({ error: "Access denied" });
}
// Get the pending track
const pendingTrack = await prisma.playlistPendingTrack.findUnique({
where: { id: pendingTrackId },
});
if (!pendingTrack) {
sessionLog(
"PENDING-RETRY",
`Pending track not found: ${pendingTrackId}`,
"WARN"
);
return res.status(404).json({ error: "Pending track not found" });
}
sessionLog(
"PENDING-RETRY",
`Pending track: artist="${pendingTrack.spotifyArtist}" title="${pendingTrack.spotifyTitle}" album="${pendingTrack.spotifyAlbum}"`
);
// Create a DownloadJob so this retry appears in Activity (active/history)
const retryTargetId =
pendingTrack.albumMbid ||
pendingTrack.artistMbid ||
`pendingTrack:${pendingTrack.id}`;
const downloadJob = await prisma.downloadJob.create({
data: {
userId,
subject: `${pendingTrack.spotifyArtist} - ${pendingTrack.spotifyTitle}`,
type: "track",
targetMbid: retryTargetId,
artistMbid: pendingTrack.artistMbid,
status: "processing",
attempts: 1,
startedAt: new Date(),
metadata: {
downloadType: "pending-track-retry",
source: "soulseek",
playlistId,
pendingTrackId,
spotifyArtist: pendingTrack.spotifyArtist,
spotifyTitle: pendingTrack.spotifyTitle,
spotifyAlbum: pendingTrack.spotifyAlbum,
albumMbid: pendingTrack.albumMbid,
},
},
});
sessionLog(
"PENDING-RETRY",
`Created download job: downloadJobId=${downloadJob.id} target=${retryTargetId}`
);
// Import soulseek service and try to download
const { soulseekService } = await import("../services/soulseek");
const { getSystemSettings } = await import("../utils/systemSettings");
const settings = await getSystemSettings();
if (!settings?.musicPath) {
sessionLog("PENDING-RETRY", `Music path not configured`, "WARN");
await prisma.downloadJob.update({
where: { id: downloadJob.id },
data: {
status: "failed",
error: "Music path not configured",
completedAt: new Date(),
},
});
return res.status(400).json({ error: "Music path not configured" });
}
if (!settings?.soulseekUsername || !settings?.soulseekPassword) {
sessionLog(
"PENDING-RETRY",
`Soulseek credentials not configured`,
"WARN"
);
await prisma.downloadJob.update({
where: { id: downloadJob.id },
data: {
status: "failed",
error: "Soulseek credentials not configured",
completedAt: new Date(),
},
});
return res
.status(400)
.json({ error: "Soulseek credentials not configured" });
}
// Use a better album name if possible - extract from stored title or use artist name
const albumName =
pendingTrack.spotifyAlbum !== "Unknown Album"
? pendingTrack.spotifyAlbum
: pendingTrack.spotifyArtist; // Use artist as fallback folder name
console.log(
`[Retry] Starting download for: ${pendingTrack.spotifyArtist} - ${pendingTrack.spotifyTitle}`
);
sessionLog(
"PENDING-RETRY",
`Search: ${pendingTrack.spotifyArtist} - ${pendingTrack.spotifyTitle}`
);
// First do a quick search to see if track is available (15s timeout)
// This way we can tell the user immediately if it's not found
const searchResult = await soulseekService.searchTrack(
pendingTrack.spotifyArtist,
pendingTrack.spotifyTitle
);
if (!searchResult.found || searchResult.allMatches.length === 0) {
console.log(`[Retry] ✗ No results found on Soulseek`);
sessionLog("PENDING-RETRY", `No results found on Soulseek`, "INFO");
await prisma.downloadJob.update({
where: { id: downloadJob.id },
data: {
status: "failed",
error: "No matching files found",
completedAt: new Date(),
},
});
return res.status(200).json({
success: false,
message: "Track not found on Soulseek",
error: "No matching files found",
});
}
console.log(
`[Retry] ✓ Found ${searchResult.allMatches.length} results, starting download in background`
);
sessionLog(
"PENDING-RETRY",
`Found ${searchResult.allMatches.length} candidate(s); starting background download`
);
// Return immediately - download happens in background
res.json({
success: true,
message: "Download started",
note: `Found ${searchResult.allMatches.length} sources. Downloading... Track will appear after scan.`,
downloadJobId: downloadJob.id,
});
// Start download in background (don't await)
soulseekService
.downloadBestMatch(
pendingTrack.spotifyArtist,
pendingTrack.spotifyTitle,
albumName,
searchResult.allMatches,
settings.musicPath
)
.then(async (result) => {
if (result.success) {
console.log(
`[Retry] ✓ Download complete: ${result.filePath}`
);
sessionLog(
"PENDING-RETRY",
`Download complete: filePath=${result.filePath}`
);
await prisma.downloadJob.update({
where: { id: downloadJob.id },
data: {
status: "completed",
completedAt: new Date(),
metadata: {
...(downloadJob.metadata as any),
filePath: result.filePath,
},
},
});
// Trigger a library scan to add the track and reconcile pending
try {
const { scanQueue } = await import("../workers/queues");
const scanJob = await scanQueue.add(
"scan",
{
userId,
source: "retry-pending-track",
albumMbid: pendingTrack.albumMbid || undefined,
artistMbid:
pendingTrack.artistMbid || undefined,
},
{
priority: 1, // High priority
removeOnComplete: true,
}
);
console.log(
`[Retry] Queued library scan to reconcile pending tracks`
);
sessionLog(
"PENDING-RETRY",
`Queued library scan (bullJobId=${
scanJob.id ?? "unknown"
})`
);
} catch (scanError) {
console.error(
`[Retry] Failed to queue scan:`,
scanError
);
sessionLog(
"PENDING-RETRY",
`Failed to queue scan: ${
(scanError as any)?.message || scanError
}`,
"ERROR"
);
}
} else {
console.log(`[Retry] ✗ Download failed: ${result.error}`);
sessionLog(
"PENDING-RETRY",
`Download failed: ${result.error || "unknown error"}`,
"WARN"
);
await prisma.downloadJob.update({
where: { id: downloadJob.id },
data: {
status: "failed",
error: result.error || "Download failed",
completedAt: new Date(),
},
});
}
})
.catch((error) => {
console.error(`[Retry] Download error:`, error);
sessionLog(
"PENDING-RETRY",
`Download exception: ${error?.message || error}`,
"ERROR"
);
prisma.downloadJob
.update({
where: { id: downloadJob.id },
data: {
status: "failed",
error: error?.message || "Download exception",
completedAt: new Date(),
},
})
.catch(() => undefined);
});
} catch (error: any) {
console.error("Retry pending track error:", error);
sessionLog(
"PENDING-RETRY",
`Handler error: ${error?.message || error}`,
"ERROR"
);
res.status(500).json({
error: "Failed to retry download",
details: error.message,
});
}
});
/**
* POST /playlists/:id/pending/reconcile
* Manually trigger reconciliation for a specific playlist
*/
router.post("/:id/pending/reconcile", async (req, res) => {
try {
const userId = req.user.id;
const playlistId = req.params.id;
// Check ownership
const playlist = await prisma.playlist.findUnique({
where: { id: playlistId },
});
if (!playlist) {
return res.status(404).json({ error: "Playlist not found" });
}
if (playlist.userId !== userId) {
return res.status(403).json({ error: "Access denied" });
}
// Import and run reconciliation
const { spotifyImportService } = await import(
"../services/spotifyImport"
);
const result = await spotifyImportService.reconcilePendingTracks();
res.json({
message: "Reconciliation complete",
tracksAdded: result.tracksAdded,
playlistsUpdated: result.playlistsUpdated,
});
} catch (error) {
console.error("Reconcile pending tracks error:", error);
res.status(500).json({ error: "Failed to reconcile pending tracks" });
}
});
export default router;
+84
View File
@@ -0,0 +1,84 @@
import { Router } from "express";
import { requireAuth } from "../middleware/auth";
import { prisma } from "../utils/db";
import { z } from "zod";
const router = Router();
router.use(requireAuth);
const playSchema = z.object({
trackId: z.string(),
});
// POST /plays
router.post("/", async (req, res) => {
try {
const userId = req.session.userId!;
const { trackId } = playSchema.parse(req.body);
// Verify track exists
const track = await prisma.track.findUnique({
where: { id: trackId },
});
if (!track) {
return res.status(404).json({ error: "Track not found" });
}
const play = await prisma.play.create({
data: {
userId,
trackId,
},
});
res.json(play);
} catch (error) {
if (error instanceof z.ZodError) {
return res
.status(400)
.json({ error: "Invalid request", details: error.errors });
}
console.error("Create play error:", error);
res.status(500).json({ error: "Failed to log play" });
}
});
// GET /plays (recent plays for user)
router.get("/", async (req, res) => {
try {
const userId = req.session.userId!;
const { limit = "50" } = req.query;
const plays = await prisma.play.findMany({
where: { userId },
orderBy: { playedAt: "desc" },
take: parseInt(limit as string, 10),
include: {
track: {
include: {
album: {
include: {
artist: {
select: {
id: true,
name: true,
mbid: true,
},
},
},
},
},
},
},
});
res.json(plays);
} catch (error) {
console.error("Get plays error:", error);
res.status(500).json({ error: "Failed to get plays" });
}
});
export default router;
File diff suppressed because it is too large Load Diff
+469
View File
@@ -0,0 +1,469 @@
import { Router } from "express";
import { requireAuth, requireAuthOrToken } from "../middleware/auth";
import { prisma } from "../utils/db";
import { lastFmService } from "../services/lastfm";
const router = Router();
router.use(requireAuthOrToken);
// GET /recommendations/for-you?limit=10
router.get("/for-you", async (req, res) => {
try {
const { limit = "10" } = req.query;
const userId = req.user!.id;
const limitNum = parseInt(limit as string, 10);
// Get user's most played artists
const recentPlays = await prisma.play.findMany({
where: { userId },
orderBy: { playedAt: "desc" },
take: 50,
include: {
track: {
include: {
album: {
include: {
artist: true,
},
},
},
},
},
});
// Count plays per artist
const artistPlayCounts = new Map<
string,
{ artist: any; count: number }
>();
for (const play of recentPlays) {
const artist = play.track.album.artist;
const existing = artistPlayCounts.get(artist.id);
if (existing) {
existing.count++;
} else {
artistPlayCounts.set(artist.id, { artist, count: 1 });
}
}
// Sort by play count and get top 3 seed artists
const topArtists = Array.from(artistPlayCounts.values())
.sort((a, b) => b.count - a.count)
.slice(0, 3);
if (topArtists.length === 0) {
// No listening history, return empty recommendations
return res.json({ artists: [] });
}
// Get similar artists for each top artist
const allSimilarArtists = await Promise.all(
topArtists.map(async ({ artist }) => {
const similar = await prisma.similarArtist.findMany({
where: { fromArtistId: artist.id },
orderBy: { weight: "desc" },
take: 10,
include: {
toArtist: {
select: {
id: true,
mbid: true,
name: true,
heroUrl: true,
},
},
},
});
return similar.map((s) => s.toArtist);
})
);
// Flatten and deduplicate
const recommendedArtists = Array.from(
new Map(
allSimilarArtists.flat().map((artist) => [artist.id, artist])
).values()
);
// Filter out artists user already owns (from native library)
const ownedArtists = await prisma.ownedAlbum.findMany({
select: { artistId: true },
distinct: ["artistId"],
});
const ownedArtistIds = new Set(ownedArtists.map((a) => a.artistId));
console.log(
`Filtering recommendations: ${ownedArtistIds.size} owned artists to exclude`
);
const newArtists = recommendedArtists.filter(
(artist) => !ownedArtistIds.has(artist.id)
);
// Get album counts for recommended artists (from enriched discography)
const recommendedArtistIds = newArtists
.slice(0, limitNum)
.map((a) => a.id);
const albumCounts = await prisma.album.groupBy({
by: ["artistId"],
where: { artistId: { in: recommendedArtistIds } },
_count: { rgMbid: true },
});
const albumCountMap = new Map(
albumCounts.map((ac) => [ac.artistId, ac._count.rgMbid])
);
// ========== CACHE-ONLY IMAGE LOOKUP FOR RECOMMENDATIONS ==========
// Only use cached data (DB heroUrl or Redis cache) - no API calls during page loads
// Background enrichment worker will populate cache over time
const { redisClient } = await import("../utils/redis");
// Get all cached images in a single Redis call for efficiency
const artistsToCheck = newArtists.slice(0, limitNum);
const cacheKeys = artistsToCheck
.filter(a => !a.heroUrl)
.map(a => `hero:${a.id}`);
let cachedImages: (string | null)[] = [];
if (cacheKeys.length > 0) {
try {
cachedImages = await redisClient.mGet(cacheKeys);
} catch (err) {
// Redis errors are non-critical
}
}
// Build a map from cache results
const cachedImageMap = new Map<string, string>();
let cacheIndex = 0;
for (const artist of artistsToCheck) {
if (!artist.heroUrl) {
const cached = cachedImages[cacheIndex];
if (cached && cached !== "NOT_FOUND") {
cachedImageMap.set(artist.id, cached);
}
cacheIndex++;
}
}
const artistsWithMetadata = artistsToCheck.map((artist) => {
// Use DB heroUrl first, then Redis cache, otherwise null
const coverArt = artist.heroUrl || cachedImageMap.get(artist.id) || null;
return {
...artist,
coverArt,
albumCount: albumCountMap.get(artist.id) || 0,
};
});
console.log(
`Recommendations: Found ${artistsWithMetadata.length} new artists`
);
artistsWithMetadata.forEach((a) => {
console.log(
` ${a.name}: coverArt=${a.coverArt ? "YES" : "NO"}, albums=${
a.albumCount
}`
);
});
res.json({ artists: artistsWithMetadata });
} catch (error) {
console.error("Get recommendations for you error:", error);
res.status(500).json({ error: "Failed to get recommendations" });
}
});
// GET /recommendations?seedArtistId=
router.get("/", async (req, res) => {
try {
const { seedArtistId } = req.query;
if (!seedArtistId) {
return res.status(400).json({ error: "seedArtistId required" });
}
// Get seed artist
const seedArtist = await prisma.artist.findUnique({
where: { id: seedArtistId as string },
});
if (!seedArtist) {
return res.status(404).json({ error: "Artist not found" });
}
// Get similar artists from database
const similarArtists = await prisma.similarArtist.findMany({
where: { fromArtistId: seedArtistId as string },
orderBy: { weight: "desc" },
take: 20,
});
// Fetch full artist details for each similar artist
const recommendations = await Promise.all(
similarArtists.map(async (similar) => {
const artist = await prisma.artist.findUnique({
where: { id: similar.toArtistId },
});
const albums = await prisma.album.findMany({
where: { artistId: similar.toArtistId },
orderBy: { year: "desc" },
take: 3,
});
const ownedAlbums = await prisma.ownedAlbum.findMany({
where: { artistId: similar.toArtistId },
});
const ownedRgMbids = new Set(ownedAlbums.map((o) => o.rgMbid));
return {
artist: {
id: artist?.id,
mbid: artist?.mbid,
name: artist?.name,
heroUrl: artist?.heroUrl,
},
similarity: similar.weight,
topAlbums: albums.map((album) => ({
...album,
owned: ownedRgMbids.has(album.rgMbid),
})),
};
})
);
res.json({
seedArtist: {
id: seedArtist.id,
name: seedArtist.name,
},
recommendations,
});
} catch (error) {
console.error("Get recommendations error:", error);
res.status(500).json({ error: "Failed to get recommendations" });
}
});
// GET /recommendations/albums?seedAlbumId=
router.get("/albums", async (req, res) => {
try {
const { seedAlbumId } = req.query;
if (!seedAlbumId) {
return res.status(400).json({ error: "seedAlbumId required" });
}
// Get seed album
const seedAlbum = await prisma.album.findUnique({
where: { id: seedAlbumId as string },
include: {
artist: true,
tracks: {
include: {
trackGenres: {
include: {
genre: true,
},
},
},
},
},
});
if (!seedAlbum) {
return res.status(404).json({ error: "Album not found" });
}
// Get genre tags from the album's tracks
const genreTags = Array.from(
new Set(
seedAlbum.tracks.flatMap((track) =>
track.trackGenres.map((tg) => tg.genre.name)
)
)
);
// Strategy 1: Get albums from similar artists
const similarArtists = await prisma.similarArtist.findMany({
where: { fromArtistId: seedAlbum.artistId },
orderBy: { weight: "desc" },
take: 10,
});
const similarArtistAlbums = await prisma.album.findMany({
where: {
artistId: { in: similarArtists.map((sa) => sa.toArtistId) },
id: { not: seedAlbumId as string }, // Exclude seed album
},
include: {
artist: true,
},
orderBy: { year: "desc" },
take: 15,
});
// Strategy 2: Get albums with matching genres
let genreMatchAlbums: any[] = [];
if (genreTags.length > 0) {
genreMatchAlbums = await prisma.album.findMany({
where: {
id: { not: seedAlbumId as string },
tracks: {
some: {
trackGenres: {
some: {
genre: {
name: { in: genreTags },
},
},
},
},
},
},
include: {
artist: true,
},
take: 10,
});
}
// Combine and deduplicate
const allAlbums = [...similarArtistAlbums, ...genreMatchAlbums];
const uniqueAlbums = Array.from(
new Map(allAlbums.map((album) => [album.id, album])).values()
);
// Check ownership
const recommendations = await Promise.all(
uniqueAlbums.slice(0, 20).map(async (album) => {
const ownedAlbums = await prisma.ownedAlbum.findMany({
where: { artistId: album.artistId },
});
const ownedRgMbids = new Set(ownedAlbums.map((o) => o.rgMbid));
return {
...album,
owned: ownedRgMbids.has(album.rgMbid),
};
})
);
res.json({
seedAlbum: {
id: seedAlbum.id,
title: seedAlbum.title,
artist: seedAlbum.artist.name,
},
recommendations,
});
} catch (error) {
console.error("Get album recommendations error:", error);
res.status(500).json({
error: "Failed to get album recommendations",
});
}
});
// GET /recommendations/tracks?seedTrackId=
router.get("/tracks", async (req, res) => {
try {
const { seedTrackId } = req.query;
if (!seedTrackId) {
return res.status(400).json({ error: "seedTrackId required" });
}
// Get seed track
const seedTrack = await prisma.track.findUnique({
where: { id: seedTrackId as string },
include: {
album: {
include: {
artist: true,
},
},
},
});
if (!seedTrack) {
return res.status(404).json({ error: "Track not found" });
}
// Use Last.fm to get similar tracks
const similarTracksFromLastFm = await lastFmService.getSimilarTracks(
seedTrack.album.artist.name,
seedTrack.title,
20
);
// Try to match similar tracks in our library
const recommendations = [];
for (const lfmTrack of similarTracksFromLastFm) {
const matchedTracks = await prisma.track.findMany({
where: {
title: {
contains: lfmTrack.name,
mode: "insensitive",
},
album: {
artist: {
name: {
contains: lfmTrack.artist?.name || "",
mode: "insensitive",
},
},
},
},
include: {
album: {
include: {
artist: true,
},
},
},
take: 1,
});
if (matchedTracks.length > 0) {
recommendations.push({
...matchedTracks[0],
inLibrary: true,
similarity: lfmTrack.match || 0,
});
} else {
// Include Last.fm suggestion even if not in library
recommendations.push({
title: lfmTrack.name,
artist: lfmTrack.artist?.name || "Unknown",
inLibrary: false,
similarity: lfmTrack.match || 0,
lastFmUrl: lfmTrack.url,
});
}
}
res.json({
seedTrack: {
id: seedTrack.id,
title: seedTrack.title,
artist: seedTrack.album.artist.name,
album: seedTrack.album.title,
},
recommendations,
});
} catch (error) {
console.error("Get track recommendations error:", error);
res.status(500).json({
error: "Failed to get track recommendations",
});
}
});
export default router;
+259
View File
@@ -0,0 +1,259 @@
/**
* 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)
*/
import { Router } from "express";
import { lidarrService, CalendarRelease } from "../services/lidarr";
import { prisma } from "../utils/db";
const router = Router();
interface ReleaseRadarResponse {
upcoming: ReleaseItem[];
recent: ReleaseItem[];
monitoredArtistCount: number;
similarArtistCount: number;
}
interface ReleaseItem {
id: number | string;
title: string;
artistName: string;
artistMbid?: string;
albumMbid: string;
releaseDate: string;
coverUrl: string | null;
source: 'lidarr' | 'similar';
status: 'upcoming' | 'released' | 'available';
inLibrary: boolean;
canDownload: boolean;
}
/**
* GET /releases/radar
*
* Get upcoming and recent releases for the user's monitored artists
* and their similar artists.
*/
router.get("/radar", async (req, res) => {
try {
const now = new Date();
const daysBack = parseInt(req.query.daysBack as string) || 30;
const daysAhead = parseInt(req.query.daysAhead as string) || 90;
// Calculate date range
const startDate = new Date(now);
startDate.setDate(startDate.getDate() - daysBack);
const endDate = new Date(now);
endDate.setDate(endDate.getDate() + daysAhead);
console.log(`[Releases] Fetching radar: ${daysBack} days back, ${daysAhead} days ahead`);
// 1. Get releases from Lidarr calendar (monitored artists)
const lidarrReleases = await lidarrService.getCalendar(startDate, endDate);
// 2. Get monitored artists from Lidarr
const monitoredArtists = await lidarrService.getMonitoredArtists();
const monitoredMbids = new Set(monitoredArtists.map(a => a.mbid));
// 3. Get similar artists from user's library that aren't monitored
const similarArtists = await prisma.similarArtist.findMany({
where: {
// Source artist is in the library (has albums)
fromArtist: {
albums: { some: {} }
},
// Target artist is NOT in library (no albums)
toArtist: {
albums: { none: {} }
}
},
select: {
toArtist: {
select: {
id: true,
name: true,
mbid: true,
}
},
weight: true,
},
orderBy: { weight: 'desc' },
take: 50, // Top 50 similar artists
});
// Filter out any that are already monitored in Lidarr
const unmonitoredSimilar = similarArtists.filter(
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`);
// 4. Get albums in library to check what user already has
const libraryAlbums = await prisma.album.findMany({
select: {
rgMbid: true,
}
});
const libraryAlbumMbids = new Set(libraryAlbums.map(a => a.rgMbid).filter(Boolean));
// 5. Transform Lidarr releases
const releases: ReleaseItem[] = lidarrReleases.map(release => {
const releaseTime = new Date(release.releaseDate).getTime();
const isUpcoming = releaseTime > now.getTime();
const inLibrary = release.hasFile || libraryAlbumMbids.has(release.albumMbid);
return {
id: release.id,
title: release.title,
artistName: release.artistName,
artistMbid: release.artistMbid,
albumMbid: release.albumMbid,
releaseDate: release.releaseDate,
coverUrl: release.coverUrl,
source: 'lidarr' as const,
status: isUpcoming ? 'upcoming' : (inLibrary ? 'available' : 'released'),
inLibrary,
canDownload: !inLibrary && !isUpcoming,
};
});
// 6. Split into upcoming and recent
const upcoming = releases
.filter(r => r.status === 'upcoming')
.sort((a, b) => new Date(a.releaseDate).getTime() - new Date(b.releaseDate).getTime());
const recent = releases
.filter(r => r.status !== 'upcoming')
.sort((a, b) => new Date(b.releaseDate).getTime() - new Date(a.releaseDate).getTime());
const response: ReleaseRadarResponse = {
upcoming,
recent,
monitoredArtistCount: monitoredArtists.length,
similarArtistCount: unmonitoredSimilar.length,
};
res.json(response);
} catch (error: any) {
console.error("[Releases] Radar error:", error.message);
res.status(500).json({ error: "Failed to fetch release radar" });
}
});
/**
* GET /releases/upcoming
*
* Get only upcoming releases (next X days)
*/
router.get("/upcoming", async (req, res) => {
try {
const daysAhead = parseInt(req.query.days as string) || 90;
const now = new Date();
const endDate = new Date(now);
endDate.setDate(endDate.getDate() + daysAhead);
const releases = await lidarrService.getCalendar(now, endDate);
// Sort by release date (soonest first)
const sorted = releases.sort((a, b) =>
new Date(a.releaseDate).getTime() - new Date(b.releaseDate).getTime()
);
res.json({
releases: sorted,
count: sorted.length,
daysAhead,
});
} catch (error: any) {
console.error("[Releases] Upcoming error:", error.message);
res.status(500).json({ error: "Failed to fetch upcoming releases" });
}
});
/**
* GET /releases/recent
*
* Get recently released albums (last X days) that user might want to download
*/
router.get("/recent", async (req, res) => {
try {
const daysBack = parseInt(req.query.days as string) || 30;
const now = new Date();
const startDate = new Date(now);
startDate.setDate(startDate.getDate() - daysBack);
const releases = await lidarrService.getCalendar(startDate, now);
// 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));
// Filter to releases not in library and sort (newest first)
const notInLibrary = releases
.filter(r => !r.hasFile && !libraryMbids.has(r.albumMbid))
.sort((a, b) =>
new Date(b.releaseDate).getTime() - new Date(a.releaseDate).getTime()
);
res.json({
releases: notInLibrary,
count: notInLibrary.length,
daysBack,
inLibraryCount: releases.length - notInLibrary.length,
});
} catch (error: any) {
console.error("[Releases] Recent error:", error.message);
res.status(500).json({ error: "Failed to fetch recent releases" });
}
});
/**
* POST /releases/download/:albumMbid
*
* Download a release from the radar
*/
router.post("/download/:albumMbid", async (req, res) => {
try {
const { albumMbid } = req.params;
const userId = req.user?.id;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
console.log(`[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"
});
}
} catch (error: any) {
console.error("[Releases] Download error:", error.message);
res.status(500).json({ error: "Failed to start download" });
}
});
export default router;
+432
View File
@@ -0,0 +1,432 @@
import { Router } from "express";
import { requireAuth } from "../middleware/auth";
import { prisma } from "../utils/db";
import { audiobookshelfService } from "../services/audiobookshelf";
import { lastFmService } from "../services/lastfm";
import { searchService } from "../services/search";
import axios from "axios";
import { redisClient } from "../utils/redis";
const router = Router();
router.use(requireAuth);
/**
* @openapi
* /search:
* get:
* summary: Search across your music library
* description: Search for artists, albums, tracks, audiobooks, and podcasts in your library using PostgreSQL full-text search
* tags: [Search]
* security:
* - sessionAuth: []
* - apiKeyAuth: []
* parameters:
* - in: query
* name: q
* schema:
* type: string
* required: true
* description: Search query
* example: "radiohead"
* - in: query
* name: type
* schema:
* type: string
* enum: [all, artists, albums, tracks, audiobooks, podcasts]
* description: Type of content to search
* default: all
* - in: query
* name: genre
* schema:
* type: string
* description: Filter tracks by genre
* - in: query
* name: limit
* schema:
* type: integer
* minimum: 1
* maximum: 100
* description: Maximum number of results per type
* default: 20
* responses:
* 200:
* description: Search results
* content:
* application/json:
* schema:
* type: object
* properties:
* artists:
* type: array
* items:
* $ref: '#/components/schemas/Artist'
* albums:
* type: array
* items:
* $ref: '#/components/schemas/Album'
* tracks:
* type: array
* items:
* $ref: '#/components/schemas/Track'
* audiobooks:
* type: array
* items:
* type: object
* podcasts:
* type: array
* items:
* type: object
* 401:
* description: Not authenticated
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
router.get("/", async (req, res) => {
try {
const { q = "", type = "all", genre, limit = "20" } = req.query;
const query = (q as string).trim();
const searchLimit = Math.min(parseInt(limit as string, 10), 100);
if (!query) {
return res.json({
artists: [],
albums: [],
tracks: [],
audiobooks: [],
podcasts: [],
});
}
// Check cache for library search (short TTL since library can change)
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}"`);
return res.json(JSON.parse(cached));
}
} catch (err) {
// Redis errors are non-critical
}
const results: any = {
artists: [],
albums: [],
tracks: [],
audiobooks: [],
podcasts: [],
};
// Search artists using full-text search (only show artists with actual albums in library)
if (type === "all" || type === "artists") {
const artistResults = await searchService.searchArtists({
query,
limit: searchLimit,
});
// Filter to only include artists with albums
const artistIds = artistResults.map((a) => a.id);
const artistsWithAlbums = await prisma.artist.findMany({
where: {
id: { in: artistIds },
albums: {
some: {},
},
},
select: {
id: true,
mbid: true,
name: true,
heroUrl: true,
summary: true,
},
});
// Preserve rank order from full-text search
const rankMap = new Map(artistResults.map((a) => [a.id, a.rank]));
results.artists = artistsWithAlbums.sort((a, b) => {
const rankA = rankMap.get(a.id) || 0;
const rankB = rankMap.get(b.id) || 0;
return rankB - rankA; // Sort by rank DESC
});
}
// Search albums using full-text search
if (type === "all" || type === "albums") {
const albumResults = await searchService.searchAlbums({
query,
limit: searchLimit,
});
results.albums = albumResults.map((album) => ({
id: album.id,
title: album.title,
artistId: album.artistId,
year: album.year,
coverUrl: album.coverUrl,
artist: {
id: album.artistId,
name: album.artistName,
mbid: "", // Not included in search result
},
}));
}
// Search tracks using full-text search
if (type === "all" || type === "tracks") {
const trackResults = await searchService.searchTracks({
query,
limit: searchLimit,
});
// If genre filter is applied, filter the results
if (genre) {
const trackIds = trackResults.map((t) => t.id);
const tracksWithGenre = await prisma.track.findMany({
where: {
id: { in: trackIds },
trackGenres: {
some: {
genre: {
name: {
equals: genre as string,
mode: "insensitive",
},
},
},
},
},
select: { id: true },
});
const genreTrackIds = new Set(tracksWithGenre.map((t) => t.id));
results.tracks = trackResults
.filter((t) => genreTrackIds.has(t.id))
.map((track) => ({
id: track.id,
title: track.title,
albumId: track.albumId,
duration: track.duration,
trackNo: 0, // Not included in search result
album: {
id: track.albumId,
title: track.albumTitle,
artistId: track.artistId,
coverUrl: null, // Not included in search result
artist: {
id: track.artistId,
name: track.artistName,
mbid: "", // Not included in search result
},
},
}));
} else {
results.tracks = trackResults.map((track) => ({
id: track.id,
title: track.title,
albumId: track.albumId,
duration: track.duration,
trackNo: 0, // Not included in search result
album: {
id: track.albumId,
title: track.albumTitle,
artistId: track.artistId,
coverUrl: null, // Not included in search result
artist: {
id: track.artistId,
name: track.artistName,
mbid: "", // Not included in search result
},
},
}));
}
}
// Search audiobooks
if (type === "all" || type === "audiobooks") {
try {
const audiobooks = await audiobookshelfService.searchAudiobooks(
query
);
results.audiobooks = audiobooks.slice(0, searchLimit);
} catch (error) {
console.error("Audiobook search error:", error);
results.audiobooks = [];
}
}
// Search podcasts (search through owned podcasts)
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);
} catch (error) {
console.error("Podcast search error:", error);
results.podcasts = [];
}
}
// Cache search results for 2 minutes (library can change)
try {
await redisClient.setEx(cacheKey, 120, JSON.stringify(results));
} catch (err) {
// Redis errors are non-critical
}
res.json(results);
} catch (error) {
console.error("Search error:", error);
res.status(500).json({ error: "Search failed" });
}
});
// GET /search/genres
router.get("/genres", async (req, res) => {
try {
const genres = await prisma.genre.findMany({
orderBy: { name: "asc" },
include: {
_count: {
select: { trackGenres: true },
},
},
});
res.json(
genres.map((g) => ({
id: g.id,
name: g.name,
trackCount: g._count.trackGenres,
}))
);
} catch (error) {
console.error("Get genres error:", error);
res.status(500).json({ error: "Failed to get genres" });
}
});
/**
* GET /search/discover?q=query&type=music|podcasts
* Search for NEW content to discover (not in your library)
*/
router.get("/discover", async (req, res) => {
try {
const { q = "", type = "music", limit = "20" } = req.query;
const query = (q as string).trim();
const searchLimit = Math.min(parseInt(limit as string, 10), 50);
if (!query) {
return res.json({ results: [] });
}
const cacheKey = `search:discover:${type}:${query}:${searchLimit}`;
try {
const cached = await redisClient.get(cacheKey);
if (cached) {
console.log(
`[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);
}
const results: any[] = [];
if (type === "music" || type === "all") {
// Search Last.fm for artists AND tracks
try {
// Search for artists
const lastfmArtistResults = await lastFmService.searchArtists(
query,
searchLimit
);
console.log(
`[SEARCH ENDPOINT] Found ${lastfmArtistResults.length} artist results`
);
results.push(...lastfmArtistResults);
// Search for tracks (songs)
const lastfmTrackResults = await lastFmService.searchTracks(
query,
searchLimit
);
console.log(
`[SEARCH ENDPOINT] Found ${lastfmTrackResults.length} track results`
);
results.push(...lastfmTrackResults);
} catch (error) {
console.error("Last.fm search error:", error);
}
}
if (type === "podcasts" || type === "all") {
// Search iTunes Podcast API
try {
const itunesResponse = await axios.get(
"https://itunes.apple.com/search",
{
params: {
term: query,
media: "podcast",
entity: "podcast",
limit: searchLimit,
},
timeout: 5000,
}
);
const podcasts = itunesResponse.data.results.map(
(podcast: any) => ({
type: "podcast",
id: podcast.collectionId,
name: podcast.collectionName,
artist: podcast.artistName,
description: podcast.description,
coverUrl:
podcast.artworkUrl600 || podcast.artworkUrl100,
feedUrl: podcast.feedUrl,
genres: podcast.genres || [],
trackCount: podcast.trackCount,
})
);
results.push(...podcasts);
} catch (error) {
console.error("iTunes podcast search error:", error);
}
}
const payload = { results };
try {
await redisClient.setEx(cacheKey, 900, JSON.stringify(payload));
} catch (err) {
console.warn("[SEARCH DISCOVER] Redis write error:", err);
}
res.json(payload);
} catch (error) {
console.error("Discovery search error:", error);
res.status(500).json({ error: "Discovery search failed" });
}
});
export default router;
+73
View File
@@ -0,0 +1,73 @@
import { Router } from "express";
import { requireAuth } from "../middleware/auth";
import { prisma } from "../utils/db";
import { z } from "zod";
const router = Router();
router.use(requireAuth);
const settingsSchema = z.object({
playbackQuality: z.enum(["original", "high", "medium", "low"]).optional(),
wifiOnly: z.boolean().optional(),
offlineEnabled: z.boolean().optional(),
maxCacheSizeMb: z.number().int().min(0).optional(),
});
// GET /settings
router.get("/", async (req, res) => {
try {
const userId = req.user!.id;
let settings = await prisma.userSettings.findUnique({
where: { userId },
});
// Create default settings if they don't exist
if (!settings) {
settings = await prisma.userSettings.create({
data: {
userId,
playbackQuality: "medium",
wifiOnly: false,
offlineEnabled: false,
maxCacheSizeMb: 5120,
},
});
}
res.json(settings);
} catch (error) {
console.error("Get settings error:", error);
res.status(500).json({ error: "Failed to get settings" });
}
});
// POST /settings
router.post("/", async (req, res) => {
try {
const userId = req.user!.id;
const data = settingsSchema.parse(req.body);
const settings = await prisma.userSettings.upsert({
where: { userId },
create: {
userId,
...data,
},
update: data,
});
res.json(settings);
} catch (error) {
if (error instanceof z.ZodError) {
return res
.status(400)
.json({ error: "Invalid settings", details: error.errors });
}
console.error("Update settings error:", error);
res.status(500).json({ error: "Failed to update settings" });
}
});
export default router;
+193
View File
@@ -0,0 +1,193 @@
/**
* Soulseek routes - Direct connection via slsk-client
* Simplified API for status and manual search/download
*/
import { Router } from "express";
import { requireAuth } from "../middleware/auth";
import { soulseekService } from "../services/soulseek";
import { getSystemSettings } from "../utils/systemSettings";
const router = Router();
// Middleware to check if Soulseek credentials are configured
async function requireSoulseekConfigured(req: any, res: any, next: any) {
try {
const available = await soulseekService.isAvailable();
if (!available) {
return res.status(403).json({
error: "Soulseek credentials not configured. Add username/password in System Settings.",
});
}
next();
} catch (error) {
console.error("Error checking Soulseek settings:", error);
res.status(500).json({ error: "Failed to check settings" });
}
}
/**
* GET /soulseek/status
* Check connection status
*/
router.get("/status", requireAuth, async (req, res) => {
try {
const available = await soulseekService.isAvailable();
if (!available) {
return res.json({
enabled: false,
connected: false,
message: "Soulseek credentials not configured",
});
}
const status = await soulseekService.getStatus();
res.json({
enabled: true,
connected: status.connected,
username: status.username,
});
} catch (error: any) {
console.error("Soulseek status error:", error.message);
res.status(500).json({
error: "Failed to get Soulseek status",
details: error.message,
});
}
});
/**
* POST /soulseek/connect
* Manually trigger connection to Soulseek network
*/
router.post("/connect", requireAuth, requireSoulseekConfigured, async (req, res) => {
try {
await soulseekService.connect();
res.json({
success: true,
message: "Connected to Soulseek network",
});
} catch (error: any) {
console.error("Soulseek connect error:", error.message);
res.status(500).json({
error: "Failed to connect to Soulseek",
details: error.message,
});
}
});
/**
* POST /soulseek/search
* Search for a track
*/
router.post("/search", requireAuth, requireSoulseekConfigured, async (req, res) => {
try {
const { artist, title } = req.body;
if (!artist || !title) {
return res.status(400).json({
error: "Artist and title are required",
});
}
console.log(`[Soulseek] Searching: "${artist} - ${title}"`);
const result = await soulseekService.searchTrack(artist, title);
if (result.found && result.bestMatch) {
res.json({
found: true,
match: {
user: result.bestMatch.username,
filename: result.bestMatch.filename,
size: result.bestMatch.size,
quality: result.bestMatch.quality,
score: result.bestMatch.score,
},
});
} else {
res.json({
found: false,
message: "No suitable matches found",
});
}
} catch (error: any) {
console.error("Soulseek search error:", error.message);
res.status(500).json({
error: "Search failed",
details: error.message,
});
}
});
/**
* POST /soulseek/download
* Download a track directly
*/
router.post("/download", requireAuth, requireSoulseekConfigured, async (req, res) => {
try {
const { artist, title, album } = req.body;
if (!artist || !title) {
return res.status(400).json({
error: "Artist and title are required",
});
}
const settings = await getSystemSettings();
const musicPath = settings?.musicPath;
if (!musicPath) {
return res.status(400).json({
error: "Music path not configured",
});
}
console.log(`[Soulseek] Downloading: "${artist} - ${title}"`);
const result = await soulseekService.searchAndDownload(
artist,
title,
album || "Unknown Album",
musicPath
);
if (result.success) {
res.json({
success: true,
filePath: result.filePath,
});
} else {
res.status(404).json({
success: false,
error: result.error || "Download failed",
});
}
} catch (error: any) {
console.error("Soulseek download error:", error.message);
res.status(500).json({
error: "Download failed",
details: error.message,
});
}
});
/**
* POST /soulseek/disconnect
* Disconnect from Soulseek network
*/
router.post("/disconnect", requireAuth, async (req, res) => {
try {
soulseekService.disconnect();
res.json({ success: true, message: "Disconnected" });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
export default router;
+334
View File
@@ -0,0 +1,334 @@
import { Router } from "express";
import { requireAuthOrToken } from "../middleware/auth";
import { z } from "zod";
import { spotifyService } from "../services/spotify";
import { spotifyImportService } from "../services/spotifyImport";
import { deezerService } from "../services/deezer";
import { readSessionLog, getSessionLogPath } from "../utils/playlistLogger";
const router = Router();
// All routes require authentication
router.use(requireAuthOrToken);
// Validation schemas
const parseUrlSchema = z.object({
url: z.string().url(),
});
const importSchema = z.object({
spotifyPlaylistId: z.string(),
url: z.string().url().optional(),
playlistName: z.string().min(1).max(200),
albumMbidsToDownload: z.array(z.string()),
});
/**
* POST /api/spotify/parse
* Parse a Spotify URL and return basic info
*/
router.post("/parse", async (req, res) => {
try {
const { url } = parseUrlSchema.parse(req.body);
const parsed = spotifyService.parseUrl(url);
if (!parsed) {
return res.status(400).json({
error: "Invalid Spotify URL. Please provide a valid playlist URL.",
});
}
// For now, only support playlists
if (parsed.type !== "playlist") {
return res.status(400).json({
error: `Only playlist imports are supported. Got: ${parsed.type}`,
});
}
res.json({
type: parsed.type,
id: parsed.id,
url: `https://open.spotify.com/playlist/${parsed.id}`,
});
} catch (error: any) {
console.error("Spotify parse error:", error);
if (error.name === "ZodError") {
return res.status(400).json({ error: "Invalid request body" });
}
res.status(500).json({ error: error.message || "Failed to parse URL" });
}
});
/**
* POST /api/spotify/preview
* Generate a preview of what will be imported from a Spotify or Deezer playlist
*/
router.post("/preview", async (req, res) => {
try {
const { url } = parseUrlSchema.parse(req.body);
console.log(`[Playlist Import] Generating preview for: ${url}`);
// Detect if it's a Deezer URL
if (url.includes("deezer.com")) {
// Extract playlist ID from Deezer URL
const deezerMatch = url.match(/playlist[\/:](\d+)/);
if (!deezerMatch) {
return res
.status(400)
.json({ error: "Invalid Deezer playlist URL" });
}
const playlistId = deezerMatch[1];
const deezerPlaylist = await deezerService.getPlaylist(playlistId);
if (!deezerPlaylist) {
return res
.status(404)
.json({ error: "Deezer playlist not found" });
}
// Convert Deezer format to Spotify Import format
const preview =
await spotifyImportService.generatePreviewFromDeezer(
deezerPlaylist
);
console.log(
`[Playlist Import] Deezer preview generated: ${preview.summary.total} tracks, ${preview.summary.inLibrary} in library`
);
res.json(preview);
} else {
// Handle Spotify URL
const preview = await spotifyImportService.generatePreview(url);
console.log(
`[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);
if (error.name === "ZodError") {
return res.status(400).json({ error: "Invalid request body" });
}
res.status(500).json({
error: error.message || "Failed to generate preview",
});
}
});
/**
* POST /api/spotify/import
* Start importing a Spotify playlist
*/
router.post("/import", async (req, res) => {
try {
const { spotifyPlaylistId, url, playlistName, albumMbidsToDownload } =
importSchema.parse(req.body);
const userId = req.user.id;
// Re-generate preview to ensure fresh data
const effectiveUrl =
url?.trim() ||
`https://open.spotify.com/playlist/${spotifyPlaylistId}`;
let preview;
if (effectiveUrl.includes("deezer.com")) {
const deezerMatch = effectiveUrl.match(/playlist[\/:](\d+)/);
if (!deezerMatch) {
return res
.status(400)
.json({ error: "Invalid Deezer playlist URL" });
}
const playlistId = deezerMatch[1];
const deezerPlaylist = await deezerService.getPlaylist(playlistId);
if (!deezerPlaylist) {
return res
.status(404)
.json({ error: "Deezer playlist not found" });
}
preview = await spotifyImportService.generatePreviewFromDeezer(
deezerPlaylist
);
} else {
preview = await spotifyImportService.generatePreview(effectiveUrl);
}
console.log(
`[Spotify Import] Starting import for user ${userId}: ${playlistName}`
);
console.log(
`[Spotify Import] Downloading ${albumMbidsToDownload.length} albums`
);
const job = await spotifyImportService.startImport(
userId,
spotifyPlaylistId,
playlistName,
albumMbidsToDownload,
preview
);
res.json({
jobId: job.id,
status: job.status,
message: "Import started",
});
} catch (error: any) {
console.error("Spotify import error:", error);
if (error.name === "ZodError") {
return res.status(400).json({ error: "Invalid request body" });
}
res.status(500).json({
error: error.message || "Failed to start import",
});
}
});
/**
* GET /api/spotify/import/:jobId/status
* Get the status of an import job
*/
router.get("/import/:jobId/status", async (req, res) => {
try {
const { jobId } = req.params;
const userId = req.user.id;
const job = await spotifyImportService.getJob(jobId);
if (!job) {
return res.status(404).json({ error: "Import job not found" });
}
// Ensure user owns this job
if (job.userId !== userId) {
return res
.status(403)
.json({ error: "Not authorized to view this job" });
}
res.json(job);
} catch (error: any) {
console.error("Spotify job status error:", error);
res.status(500).json({
error: error.message || "Failed to get job status",
});
}
});
/**
* GET /api/spotify/imports
* Get all import jobs for the current user
*/
router.get("/imports", async (req, res) => {
try {
const userId = req.user.id;
const jobs = await spotifyImportService.getUserJobs(userId);
res.json(jobs);
} catch (error: any) {
console.error("Spotify imports error:", error);
res.status(500).json({
error: error.message || "Failed to get imports",
});
}
});
/**
* POST /api/spotify/import/:jobId/refresh
* Re-match pending tracks and add newly downloaded ones to the playlist
*/
router.post("/import/:jobId/refresh", async (req, res) => {
try {
const { jobId } = req.params;
const userId = req.user.id;
const job = await spotifyImportService.getJob(jobId);
if (!job) {
return res.status(404).json({ error: "Import job not found" });
}
// Ensure user owns this job
if (job.userId !== userId) {
return res
.status(403)
.json({ error: "Not authorized to refresh this job" });
}
const result = await spotifyImportService.refreshJobMatches(jobId);
res.json({
message:
result.added > 0
? `Added ${result.added} newly downloaded track(s)`
: "No new tracks found yet. Albums may still be downloading.",
added: result.added,
total: result.total,
});
} catch (error: any) {
console.error("Spotify refresh error:", error);
res.status(500).json({
error: error.message || "Failed to refresh tracks",
});
}
});
/**
* POST /api/spotify/import/:jobId/cancel
* Cancel an import job and create playlist with whatever succeeded
*/
router.post("/import/:jobId/cancel", async (req, res) => {
try {
const { jobId } = req.params;
const userId = req.user.id;
const job = await spotifyImportService.getJob(jobId);
if (!job) {
return res.status(404).json({ error: "Import job not found" });
}
// Ensure user owns this job
if (job.userId !== userId) {
return res
.status(403)
.json({ error: "Not authorized to cancel this job" });
}
const result = await spotifyImportService.cancelJob(jobId);
res.json({
message: result.playlistCreated
? `Import cancelled. Playlist created with ${result.tracksMatched} track(s).`
: "Import cancelled. No tracks were downloaded.",
playlistId: result.playlistId,
tracksMatched: result.tracksMatched,
});
} catch (error: any) {
console.error("Spotify cancel error:", error);
res.status(500).json({
error: error.message || "Failed to cancel import",
});
}
});
/**
* GET /api/spotify/import/session-log
* Get the current session log for debugging import issues
*/
router.get("/import/session-log", async (req, res) => {
try {
const log = readSessionLog();
const logPath = getSessionLogPath();
res.json({
path: logPath,
content: log,
});
} catch (error: any) {
console.error("Session log error:", error);
res.status(500).json({
error: error.message || "Failed to read session log",
});
}
});
export default router;
+712
View File
@@ -0,0 +1,712 @@
import { Router } from "express";
import { requireAuth, requireAdmin } from "../middleware/auth";
import { prisma } from "../utils/db";
import { z } from "zod";
import { writeEnvFile } from "../utils/envWriter";
import { invalidateSystemSettingsCache } from "../utils/systemSettings";
import { queueCleaner } from "../jobs/queueCleaner";
import { encrypt, decrypt } from "../utils/encryption";
const router = Router();
/**
* Safely decrypt a field, returning null if decryption fails
*/
function safeDecrypt(value: string | null): string | null {
if (!value) return null;
try {
return decrypt(value);
} catch (error) {
console.warn("[Settings Route] Failed to decrypt field, returning null");
return null;
}
}
// Only admins can access system settings
router.use(requireAuth);
router.use(requireAdmin);
const systemSettingsSchema = z.object({
// Download Services
lidarrEnabled: z.boolean().optional(),
lidarrUrl: z.string().optional(),
lidarrApiKey: z.string().nullable().optional(),
// AI Services
openaiEnabled: z.boolean().optional(),
openaiApiKey: z.string().nullable().optional(),
openaiModel: z.string().optional(),
openaiBaseUrl: z.string().nullable().optional(),
fanartEnabled: z.boolean().optional(),
fanartApiKey: z.string().nullable().optional(),
// Media Services
audiobookshelfEnabled: z.boolean().optional(),
audiobookshelfUrl: z.string().optional(),
audiobookshelfApiKey: z.string().nullable().optional(),
// Soulseek (direct connection via slsk-client)
soulseekUsername: z.string().nullable().optional(),
soulseekPassword: z.string().nullable().optional(),
// Spotify (for playlist import)
spotifyClientId: z.string().nullable().optional(),
spotifyClientSecret: z.string().nullable().optional(),
// Storage Paths
musicPath: z.string().optional(),
downloadPath: z.string().optional(),
// Feature Flags
autoSync: z.boolean().optional(),
autoEnrichMetadata: z.boolean().optional(),
// Advanced Settings
maxConcurrentDownloads: z.number().optional(),
downloadRetryAttempts: z.number().optional(),
transcodeCacheMaxGb: z.number().optional(),
// Download Preferences
downloadSource: z.enum(["soulseek", "lidarr"]).optional(),
soulseekFallback: z.enum(["none", "lidarr"]).optional(),
});
// GET /system-settings
router.get("/", async (req, res) => {
try {
let settings = await prisma.systemSettings.findUnique({
where: { id: "default" },
});
// Create default settings if they don't exist
if (!settings) {
settings = await prisma.systemSettings.create({
data: {
id: "default",
lidarrEnabled: true,
lidarrUrl: "http://localhost:8686",
openaiEnabled: false,
openaiModel: "gpt-4",
fanartEnabled: false,
audiobookshelfEnabled: false,
audiobookshelfUrl: "http://localhost:13378",
musicPath: "/music",
downloadPath: "/downloads",
autoSync: true,
autoEnrichMetadata: true,
maxConcurrentDownloads: 3,
downloadRetryAttempts: 3,
transcodeCacheMaxGb: 10,
},
});
}
// Decrypt sensitive fields before sending to client
// Use safeDecrypt to handle corrupted encrypted values gracefully
const decryptedSettings = {
...settings,
lidarrApiKey: safeDecrypt(settings.lidarrApiKey),
openaiApiKey: safeDecrypt(settings.openaiApiKey),
fanartApiKey: safeDecrypt(settings.fanartApiKey),
audiobookshelfApiKey: safeDecrypt(settings.audiobookshelfApiKey),
soulseekPassword: safeDecrypt(settings.soulseekPassword),
spotifyClientSecret: safeDecrypt(settings.spotifyClientSecret),
};
res.json(decryptedSettings);
} catch (error) {
console.error("Get system settings error:", error);
res.status(500).json({ error: "Failed to get system settings" });
}
});
// POST /system-settings
router.post("/", async (req, res) => {
try {
const data = systemSettingsSchema.parse(req.body);
console.log("[SYSTEM SETTINGS] Saving settings...");
console.log(
"[SYSTEM SETTINGS] transcodeCacheMaxGb:",
data.transcodeCacheMaxGb
);
// Encrypt sensitive fields
const encryptedData: any = { ...data };
if (data.lidarrApiKey)
encryptedData.lidarrApiKey = encrypt(data.lidarrApiKey);
if (data.openaiApiKey)
encryptedData.openaiApiKey = encrypt(data.openaiApiKey);
if (data.fanartApiKey)
encryptedData.fanartApiKey = encrypt(data.fanartApiKey);
if (data.audiobookshelfApiKey)
encryptedData.audiobookshelfApiKey = encrypt(
data.audiobookshelfApiKey
);
if (data.soulseekPassword)
encryptedData.soulseekPassword = encrypt(data.soulseekPassword);
if (data.spotifyClientSecret)
encryptedData.spotifyClientSecret = encrypt(data.spotifyClientSecret);
const settings = await prisma.systemSettings.upsert({
where: { id: "default" },
create: {
id: "default",
...encryptedData,
},
update: encryptedData,
});
invalidateSystemSettingsCache();
// If Audiobookshelf was disabled, clear all audiobook-related data
if (data.audiobookshelfEnabled === false) {
console.log(
"[CLEANUP] Audiobookshelf disabled - clearing all audiobook data from database"
);
try {
const deletedProgress =
await prisma.audiobookProgress.deleteMany({});
console.log(
` Deleted ${deletedProgress.count} audiobook progress entries`
);
} catch (clearError) {
console.error("Failed to clear audiobook data:", clearError);
// Don't fail the request
}
}
// Write to .env file for Docker containers
try {
await writeEnvFile({
LIDARR_ENABLED: data.lidarrEnabled ? "true" : "false",
LIDARR_URL: data.lidarrUrl || null,
LIDARR_API_KEY: data.lidarrApiKey || null,
FANART_API_KEY: data.fanartApiKey || null,
OPENAI_API_KEY: data.openaiApiKey || null,
AUDIOBOOKSHELF_URL: data.audiobookshelfUrl || null,
AUDIOBOOKSHELF_API_KEY: data.audiobookshelfApiKey || null,
SOULSEEK_USERNAME: data.soulseekUsername || null,
SOULSEEK_PASSWORD: data.soulseekPassword || null,
});
console.log(".env file synchronized with database settings");
} catch (envError) {
console.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...");
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";
const webhookUrl = `${callbackHost}/api/webhooks/lidarr`;
console.log(` 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(
`${lidarrUrl}/api/v1/notification`,
{
headers: { "X-Api-Key": apiKey },
timeout: 10000,
}
);
// Find existing Lidify webhook by name (primary) or URL pattern (fallback)
const existingWebhook = notificationsResponse.data.find(
(n: any) =>
n.implementation === "Webhook" &&
(
// Match by name
n.name === "Lidify" ||
// Or match by URL pattern (catches old webhooks with different URLs)
n.fields?.find(
(f: any) =>
f.name === "url" &&
(f.value?.includes("webhooks/lidarr") || f.value?.includes("lidify"))
)
)
);
if (existingWebhook) {
const currentUrl = existingWebhook.fields?.find((f: any) => f.name === "url")?.value;
console.log(` 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}`);
}
}
const webhookConfig = {
onGrab: true,
onReleaseImport: true,
onAlbumDownload: true,
onDownloadFailure: true,
onImportFailure: true,
onAlbumDelete: true,
onRename: true,
onHealthIssue: false,
onApplicationUpdate: false,
supportsOnGrab: true,
supportsOnReleaseImport: true,
supportsOnAlbumDownload: true,
supportsOnDownloadFailure: true,
supportsOnImportFailure: true,
supportsOnAlbumDelete: true,
supportsOnRename: true,
supportsOnHealthIssue: true,
supportsOnApplicationUpdate: true,
includeHealthWarnings: false,
name: "Lidify",
implementation: "Webhook",
implementationName: "Webhook",
configContract: "WebhookSettings",
infoLink:
"https://wiki.servarr.com/lidarr/supported#webhook",
tags: [],
fields: [
{ name: "url", value: webhookUrl },
{ name: "method", value: 1 }, // 1 = POST
{ name: "username", value: "" },
{ name: "password", value: "" },
],
};
if (existingWebhook) {
// Update existing webhook
await axios.put(
`${lidarrUrl}/api/v1/notification/${existingWebhook.id}?forceSave=true`,
{ ...existingWebhook, ...webhookConfig },
{
headers: { "X-Api-Key": apiKey },
timeout: 10000,
}
);
console.log(" Webhook updated");
} else {
// Create new webhook (use forceSave to skip test)
await axios.post(
`${lidarrUrl}/api/v1/notification?forceSave=true`,
webhookConfig,
{
headers: { "X-Api-Key": apiKey },
timeout: 10000,
}
);
console.log(" Webhook created");
}
console.log("Lidarr webhook configured automatically\n");
} catch (webhookError: any) {
console.error(
"Failed to auto-configure webhook:",
webhookError.message
);
if (webhookError.response?.data) {
console.error(
" Lidarr error details:",
JSON.stringify(webhookError.response.data, null, 2)
);
}
console.log(
" User can configure webhook manually in Lidarr UI\n"
);
// Don't fail the request if webhook config fails
}
}
res.json({
success: true,
message:
"Settings saved successfully. Restart Docker containers to apply changes.",
requiresRestart: true,
});
} catch (error) {
if (error instanceof z.ZodError) {
return res
.status(400)
.json({ error: "Invalid settings", details: error.errors });
}
console.error("Update system settings error:", error);
res.status(500).json({ error: "Failed to update system settings" });
}
});
// POST /system-settings/test-lidarr
router.post("/test-lidarr", async (req, res) => {
try {
const { url, apiKey } = req.body;
console.log("[Lidarr Test] Testing connection to:", url);
if (!url || !apiKey) {
return res
.status(400)
.json({ error: "URL and API key are required" });
}
// Normalize URL - remove trailing slash
const normalizedUrl = url.replace(/\/+$/, "");
const axios = require("axios");
const response = await axios.get(
`${normalizedUrl}/api/v1/system/status`,
{
headers: { "X-Api-Key": apiKey },
timeout: 10000,
}
);
console.log(
"[Lidarr Test] Connection successful, version:",
response.data.version
);
res.json({
success: true,
message: "Lidarr connection successful",
version: response.data.version,
});
} catch (error: any) {
console.error("[Lidarr Test] Error:", error.message);
console.error(
"[Lidarr Test] Details:",
error.response?.data || error.code
);
let details = error.message;
if (error.code === "ECONNREFUSED") {
details =
"Connection refused - check if Lidarr is running and accessible";
} else if (error.code === "ENOTFOUND") {
details = "Host not found - check the URL";
} else if (error.response?.status === 401) {
details = "Invalid API key";
} else if (error.response?.data?.message) {
details = error.response.data.message;
}
res.status(500).json({
error: "Failed to connect to Lidarr",
details,
});
}
});
// POST /system-settings/test-openai
router.post("/test-openai", async (req, res) => {
try {
const { apiKey, model } = req.body;
if (!apiKey) {
return res.status(400).json({ error: "API key is required" });
}
const axios = require("axios");
const response = await axios.post(
"https://api.openai.com/v1/chat/completions",
{
model: model || "gpt-3.5-turbo",
messages: [{ role: "user", content: "Test" }],
max_tokens: 5,
},
{
headers: { Authorization: `Bearer ${apiKey}` },
timeout: 10000,
}
);
res.json({
success: true,
message: "OpenAI connection successful",
model: response.data.model,
});
} catch (error: any) {
console.error("OpenAI test error:", error.message);
res.status(500).json({
error: "Failed to connect to OpenAI",
details: error.response?.data?.error?.message || error.message,
});
}
});
// Test Fanart.tv connection
router.post("/test-fanart", async (req, res) => {
try {
const { fanartApiKey } = req.body;
if (!fanartApiKey) {
return res.status(400).json({ error: "API key is required" });
}
const axios = require("axios");
// Test with a known artist (The Beatles MBID)
const testMbid = "b10bbbfc-cf9e-42e0-be17-e2c3e1d2600d";
const response = await axios.get(
`https://webservice.fanart.tv/v3/music/${testMbid}`,
{
params: { api_key: fanartApiKey },
timeout: 5000,
}
);
// If we get here, the API key is valid
res.json({
success: true,
message: "Fanart.tv connection successful",
});
} catch (error: any) {
console.error("Fanart.tv test error:", error.message);
if (error.response?.status === 401) {
res.status(401).json({
error: "Invalid Fanart.tv API key",
});
} else {
res.status(500).json({
error: "Failed to connect to Fanart.tv",
details: error.response?.data || error.message,
});
}
}
});
// Test Audiobookshelf connection
router.post("/test-audiobookshelf", async (req, res) => {
try {
const { url, apiKey } = req.body;
if (!url || !apiKey) {
return res
.status(400)
.json({ error: "URL and API key are required" });
}
const axios = require("axios");
const response = await axios.get(`${url}/api/libraries`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
timeout: 5000,
});
res.json({
success: true,
message: "Audiobookshelf connection successful",
libraries: response.data.libraries?.length || 0,
});
} catch (error: any) {
console.error("Audiobookshelf test error:", error.message);
if (error.response?.status === 401 || error.response?.status === 403) {
res.status(401).json({
error: "Invalid Audiobookshelf API key",
});
} else {
res.status(500).json({
error: "Failed to connect to Audiobookshelf",
details: error.response?.data || error.message,
});
}
}
});
// Test Soulseek connection (direct via slsk-client)
router.post("/test-soulseek", async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({
error: "Soulseek username and password are required",
});
}
console.log(`[SOULSEEK-TEST] Testing connection as "${username}"...`);
// Import soulseek service
const { soulseekService } = await import("../services/soulseek");
// Temporarily set credentials for test
// The service will use the provided credentials
try {
// Try to connect with the provided credentials
const slsk = require("slsk-client");
await new Promise<void>((resolve, reject) => {
slsk.connect(
{ user: username, pass: password },
(err: Error | null, client: any) => {
if (err) {
console.log(`[SOULSEEK-TEST] Connection failed: ${err.message}`);
return reject(err);
}
console.log(`[SOULSEEK-TEST] Connected successfully`);
// We don't need to keep the connection open for the test
resolve();
}
);
});
res.json({
success: true,
message: `Connected to Soulseek as "${username}"`,
soulseekUsername: username,
isConnected: true,
});
} catch (connectError: any) {
console.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);
res.status(500).json({
error: "Failed to test Soulseek connection",
details: error.message,
});
}
});
// Test Spotify credentials
router.post("/test-spotify", async (req, res) => {
try {
const { clientId, clientSecret } = req.body;
if (!clientId || !clientSecret) {
return res.status(400).json({
error: "Client ID and Client Secret are required"
});
}
// Import spotifyService to test credentials
const { spotifyService } = await import("../services/spotify");
const result = await spotifyService.testCredentials(clientId, clientSecret);
if (result.success) {
res.json({
success: true,
message: "Spotify credentials are valid",
});
} else {
res.status(401).json({
error: result.error || "Invalid Spotify credentials",
});
}
} catch (error: any) {
console.error("Spotify test error:", error.message);
res.status(500).json({
error: "Failed to test Spotify credentials",
details: error.message,
});
}
});
// Get queue cleaner status
router.get("/queue-cleaner-status", (req, res) => {
res.json(queueCleaner.getStatus());
});
// Start queue cleaner manually
router.post("/queue-cleaner/start", async (req, res) => {
try {
await queueCleaner.start();
res.json({
success: true,
message: "Queue cleaner started",
status: queueCleaner.getStatus(),
});
} catch (error: any) {
res.status(500).json({
error: "Failed to start queue cleaner",
details: error.message,
});
}
});
// Stop queue cleaner manually
router.post("/queue-cleaner/stop", (req, res) => {
queueCleaner.stop();
res.json({
success: true,
message: "Queue cleaner stopped",
status: queueCleaner.getStatus(),
});
});
// Clear all Redis caches
router.post("/clear-caches", async (req, res) => {
try {
const { redisClient } = require("../utils/redis");
const { notificationService } = await import("../services/notificationService");
// Get all keys but exclude session keys
const allKeys = await redisClient.keys("*");
const keysToDelete = allKeys.filter(
(key: string) => !key.startsWith("sess:")
);
if (keysToDelete.length > 0) {
console.log(
`[CACHE] Clearing ${
keysToDelete.length
} cache entries (excluding ${
allKeys.length - keysToDelete.length
} session keys)...`
);
for (const key of keysToDelete) {
await redisClient.del(key);
}
console.log(
`[CACHE] Successfully cleared ${keysToDelete.length} cache entries`
);
// Send notification to user
await notificationService.notifySystem(
req.user!.id,
"Caches Cleared",
`Successfully cleared ${keysToDelete.length} cache entries`
);
res.json({
success: true,
message: `Cleared ${keysToDelete.length} cache entries`,
clearedKeys: keysToDelete.length,
});
} else {
await notificationService.notifySystem(
req.user!.id,
"Caches Cleared",
"No cache entries to clear"
);
res.json({
success: true,
message: "No cache entries to clear",
clearedKeys: 0,
});
}
} catch (error: any) {
console.error("Clear caches error:", error);
res.status(500).json({
error: "Failed to clear caches",
details: error.message,
});
}
});
export default router;
+231
View File
@@ -0,0 +1,231 @@
/**
* Lidarr Webhook Handler (Refactored)
*
* Handles Lidarr webhooks for download tracking and Discovery Weekly integration.
* Uses the stateless simpleDownloadManager for all operations.
*/
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";
const router = Router();
// POST /webhooks/lidarr - Handle Lidarr webhooks
router.post("/lidarr", async (req, res) => {
try {
// Check if Lidarr is enabled before processing any webhooks
const settings = await getSystemSettings();
if (
!settings?.lidarrEnabled ||
!settings?.lidarrUrl ||
!settings?.lidarrApiKey
) {
console.log(
`[WEBHOOK] Lidarr webhook received but Lidarr is disabled. Ignoring.`
);
return res.status(202).json({
success: true,
ignored: true,
reason: "lidarr-disabled",
});
}
const eventType = req.body.eventType;
console.log(`[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));
}
switch (eventType) {
case "Grab":
await handleGrab(req.body);
break;
case "Download":
case "AlbumDownload":
case "TrackRetag":
case "Rename":
await handleDownload(req.body);
break;
case "ImportFailure":
case "DownloadFailed":
case "DownloadFailure":
await handleImportFailure(req.body);
break;
case "Health":
case "HealthIssue":
case "HealthRestored":
// Ignore health events
break;
case "Test":
console.log(" Lidarr test webhook received");
break;
default:
console.log(` Unhandled event: ${eventType}`);
}
res.json({ success: true });
} catch (error: any) {
console.error("Webhook error:", error.message);
res.status(500).json({ error: "Webhook processing failed" });
}
});
/**
* Handle Grab event (download started by Lidarr)
*/
async function handleGrab(payload: any) {
const downloadId = payload.downloadId;
const albumMbid =
payload.albums?.[0]?.foreignAlbumId || payload.albums?.[0]?.mbId;
const albumTitle = payload.albums?.[0]?.title;
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}`);
if (!downloadId) {
console.log(` Missing downloadId, skipping`);
return;
}
// Use the download manager's multi-strategy matching
const result = await simpleDownloadManager.onDownloadGrabbed(
downloadId,
albumMbid || "",
albumTitle || "",
artistName || "",
lidarrAlbumId || 0
);
if (result.matched) {
// Start queue cleaner to monitor this download
queueCleaner.start();
}
}
/**
* Handle Download event (download complete + imported)
*/
async function handleDownload(payload: any) {
const downloadId = payload.downloadId;
const albumTitle = payload.album?.title || payload.albums?.[0]?.title;
const artistName = payload.artist?.name;
const albumMbid =
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}`);
if (!downloadId) {
console.log(` Missing downloadId, skipping`);
return;
}
// Handle completion through download manager
const result = await simpleDownloadManager.onDownloadComplete(
downloadId,
albumMbid,
artistName,
albumTitle,
lidarrAlbumId
);
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
} 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...`);
await scanQueue.add("scan", {
type: "full",
source: "lidarr-import-external",
});
}
}
/**
* 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
*/
async function handleImportFailure(payload: any) {
const downloadId = payload.downloadId;
const albumMbid =
payload.album?.foreignAlbumId || payload.albums?.[0]?.foreignAlbumId;
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}`);
if (!downloadId) {
console.log(` Missing downloadId, skipping`);
return;
}
// Handle failure through download manager (handles retry logic)
await simpleDownloadManager.onImportFailed(downloadId, reason, albumMbid);
}
export default router;