diff --git a/db.ts b/db.ts index 365a464..3bb51d6 100644 --- a/db.ts +++ b/db.ts @@ -685,6 +685,125 @@ export function removeTrackFromPlaylist(playlistId: string, position: number): v } } +export function removeTracksFromPlaylist(playlistId: string, positions: number[]): void { + if (positions.length === 0) return; + + const now = Math.floor(Date.now() / 1000); + // Sort descending to remove from end first (preserves indices) + const sorted = [...positions].sort((a, b) => b - a); + + db.query("BEGIN").run(); + try { + for (const pos of sorted) { + db.query("DELETE FROM playlist_tracks WHERE playlist_id = ? AND position = ?").run(playlistId, pos); + // Reorder remaining tracks + db.query(` + UPDATE playlist_tracks + SET position = position - 1 + WHERE playlist_id = ? AND position > ? + `).run(playlistId, pos); + } + + db.query("UPDATE playlists SET updated_at = ? WHERE id = ?").run(now, playlistId); + db.query("COMMIT").run(); + } catch (e) { + db.query("ROLLBACK").run(); + throw e; + } +} + +export function movePlaylistTracks(playlistId: string, fromPositions: number[], toPosition: number): void { + if (fromPositions.length === 0) return; + + const now = Math.floor(Date.now() / 1000); + const sorted = [...fromPositions].sort((a, b) => a - b); + + db.query("BEGIN").run(); + try { + // Get tracks to move + const tracksToMove: string[] = []; + for (const pos of sorted) { + const row = db.query( + "SELECT track_id FROM playlist_tracks WHERE playlist_id = ? AND position = ?" + ).get(playlistId, pos) as { track_id: string } | null; + if (row) tracksToMove.push(row.track_id); + } + + if (tracksToMove.length === 0) { + db.query("ROLLBACK").run(); + return; + } + + // Remove tracks from current positions (from end to preserve indices) + for (let i = sorted.length - 1; i >= 0; i--) { + const pos = sorted[i]; + db.query("DELETE FROM playlist_tracks WHERE playlist_id = ? AND position = ?").run(playlistId, pos); + db.query(` + UPDATE playlist_tracks + SET position = position - 1 + WHERE playlist_id = ? AND position > ? + `).run(playlistId, pos); + } + + // Adjust target for removed items + let adjustedTarget = toPosition; + for (const pos of sorted) { + if (pos < toPosition) adjustedTarget--; + } + + // Make room at target position + db.query(` + UPDATE playlist_tracks + SET position = position + ? + WHERE playlist_id = ? AND position >= ? + `).run(tracksToMove.length, playlistId, adjustedTarget); + + // Insert tracks at new position + const insert = db.query( + "INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES (?, ?, ?)" + ); + for (let i = 0; i < tracksToMove.length; i++) { + insert.run(playlistId, tracksToMove[i], adjustedTarget + i); + } + + db.query("UPDATE playlists SET updated_at = ? WHERE id = ?").run(now, playlistId); + db.query("COMMIT").run(); + } catch (e) { + db.query("ROLLBACK").run(); + throw e; + } +} + +export function insertTracksToPlaylistAt(playlistId: string, trackIds: string[], position: number): void { + if (trackIds.length === 0) return; + + const now = Math.floor(Date.now() / 1000); + + db.query("BEGIN").run(); + try { + // Make room at position + db.query(` + UPDATE playlist_tracks + SET position = position + ? + WHERE playlist_id = ? AND position >= ? + `).run(trackIds.length, playlistId, position); + + // Insert tracks + const insert = db.query( + "INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES (?, ?, ?)" + ); + for (let i = 0; i < trackIds.length; i++) { + insert.run(playlistId, trackIds[i], position + i); + } + + db.query("UPDATE playlists SET updated_at = ? WHERE id = ?").run(now, playlistId); + db.query("COMMIT").run(); + } catch (e) { + db.query("ROLLBACK").run(); + throw e; + } +} + export function generatePlaylistShareToken(playlistId: string): string { const token = crypto.randomUUID().slice(0, 12); db.query("UPDATE playlists SET share_token = ? WHERE id = ?").run(token, playlistId); diff --git a/init.ts b/init.ts index f5465ac..0e5fe72 100644 --- a/init.ts +++ b/init.ts @@ -161,19 +161,26 @@ export async function init(): Promise { const trackTitle = normalizeForMatch(track.title || ""); const trackFilename = normalizeForMatch((track.filename || "").replace(/\.[^.]+$/, "")); // Remove extension + // Skip if both title and filename are too short + if ((!trackTitle || trackTitle.length < 5) && (!trackFilename || trackFilename.length < 5)) return; + console.log(`[ytdlp] Checking track against ${pendingPlaylistTracks.size} pending: title="${trackTitle}" filename="${trackFilename}"`); for (const [pendingTitle, pending] of pendingPlaylistTracks) { const normalizedPending = normalizeForMatch(pendingTitle); + // Skip if pending title is too short + if (!normalizedPending || normalizedPending.length < 5) continue; + // Match by title or filename (yt-dlp uses title as filename) + // Require exact match or very high overlap const matches = (trackTitle && trackTitle === normalizedPending) || (trackFilename && trackFilename === normalizedPending) || - (trackTitle && normalizedPending && trackTitle.includes(normalizedPending)) || - (trackTitle && normalizedPending && normalizedPending.includes(trackTitle)) || - (trackFilename && normalizedPending && trackFilename.includes(normalizedPending)) || - (trackFilename && normalizedPending && normalizedPending.includes(trackFilename)); + (trackTitle && normalizedPending && trackTitle.includes(normalizedPending) && normalizedPending.length >= trackTitle.length * 0.8) || + (trackTitle && normalizedPending && normalizedPending.includes(trackTitle) && trackTitle.length >= normalizedPending.length * 0.8) || + (trackFilename && normalizedPending && trackFilename.includes(normalizedPending) && normalizedPending.length >= trackFilename.length * 0.8) || + (trackFilename && normalizedPending && normalizedPending.includes(trackFilename) && trackFilename.length >= normalizedPending.length * 0.8); console.log(`[ytdlp] vs pending="${normalizedPending}" → ${matches ? "MATCH" : "no match"}`); @@ -219,18 +226,26 @@ export async function init(): Promise { for (const item of queuedItems) { const itemTitle = normalizeForMatch(item.title); + // Skip if title is too short (avoid false matches) + if (!itemTitle || itemTitle.length < 5) continue; + // Check if any library track matches for (const track of tracks) { const trackTitle = normalizeForMatch(track.title || ""); const trackFilename = normalizeForMatch((track.filename || "").replace(/\.[^.]+$/, "")); + // Skip if both track title and filename are too short + if ((!trackTitle || trackTitle.length < 5) && (!trackFilename || trackFilename.length < 5)) continue; + + // Require exact match or very high overlap (not just substring) const matches = (trackTitle && trackTitle === itemTitle) || (trackFilename && trackFilename === itemTitle) || - (trackTitle && itemTitle && trackTitle.includes(itemTitle)) || - (trackTitle && itemTitle && itemTitle.includes(trackTitle)) || - (trackFilename && itemTitle && trackFilename.includes(itemTitle)) || - (trackFilename && itemTitle && itemTitle.includes(trackFilename)); + // Only allow includes if the shorter string is at least 80% of the longer + (trackTitle && itemTitle && trackTitle.includes(itemTitle) && itemTitle.length >= trackTitle.length * 0.8) || + (trackTitle && itemTitle && itemTitle.includes(trackTitle) && trackTitle.length >= itemTitle.length * 0.8) || + (trackFilename && itemTitle && trackFilename.includes(itemTitle) && itemTitle.length >= trackFilename.length * 0.8) || + (trackFilename && itemTitle && itemTitle.includes(trackFilename) && trackFilename.length >= itemTitle.length * 0.8); if (matches) { console.log(`[ytdlp] Prescan: "${item.title}" already exists as "${track.title || track.filename}"`); diff --git a/routes/playlists.ts b/routes/playlists.ts index 20d9afd..b4bb7ad 100644 --- a/routes/playlists.ts +++ b/routes/playlists.ts @@ -9,6 +9,9 @@ import { setPlaylistTracks, addTracksToPlaylist, removeTrackFromPlaylist, + removeTracksFromPlaylist, + movePlaylistTracks, + insertTracksToPlaylistAt, generatePlaylistShareToken, removePlaylistShareToken, findUserById, @@ -154,7 +157,7 @@ export async function handleModifyPlaylistTracks(req: Request, server: any, play return Response.json({ error: "Not your playlist" }, { status: 403 }); } - let body: { add?: string[]; remove?: number[]; set?: string[] }; + let body: { add?: string[]; remove?: number[]; set?: string[]; move?: number[]; to?: number; insertAt?: number }; try { body = await req.json(); } catch { @@ -167,17 +170,24 @@ export async function handleModifyPlaylistTracks(req: Request, server: any, play return Response.json({ ok: true }); } - // Remove tracks by position (do removes first, in reverse order) - if (body.remove?.length) { - const positions = [...body.remove].sort((a, b) => b - a); - for (const pos of positions) { - removeTrackFromPlaylist(playlistId, pos); - } + // Move/reorder tracks + if (body.move?.length && typeof body.to === "number") { + movePlaylistTracks(playlistId, body.move, body.to); + return Response.json({ ok: true }); } - // Add tracks + // Remove tracks by position (bulk operation) + if (body.remove?.length) { + removeTracksFromPlaylist(playlistId, body.remove); + } + + // Add tracks (at specific position or at end) if (body.add?.length) { - addTracksToPlaylist(playlistId, body.add); + if (typeof body.insertAt === "number") { + insertTracksToPlaylistAt(playlistId, body.add, body.insertAt); + } else { + addTracksToPlaylist(playlistId, body.add); + } } return Response.json({ ok: true });