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