Initial release v1.0.0
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user