Files
lidify/backend/src/routes/playlists.ts
T
2025-12-25 18:58:06 -06:00

986 lines
32 KiB
TypeScript

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;