986 lines
32 KiB
TypeScript
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;
|