From 2803410a90be64005430941d9ebdd8b682c918f9 Mon Sep 17 00:00:00 2001 From: peterino2 Date: Fri, 6 Feb 2026 23:45:57 -0800 Subject: [PATCH] persistent playlists and auto playlist --- db.ts | 152 ++++++++++++++++++++++++++++++++++++++++++++++ init.ts | 81 ++++++++++++++++++++++++ public/styles.css | 7 ++- public/upload.js | 68 +++++++++++++++++---- routes/fetch.ts | 30 ++++++++- routes/index.ts | 5 ++ ytdlp.ts | 139 ++++++++++++++++++++++++++++++++++++++++-- 7 files changed, 463 insertions(+), 19 deletions(-) diff --git a/db.ts b/db.ts index 87ce889..365a464 100644 --- a/db.ts +++ b/db.ts @@ -694,3 +694,155 @@ export function generatePlaylistShareToken(playlistId: string): string { export function removePlaylistShareToken(playlistId: string): void { db.query("UPDATE playlists SET share_token = NULL WHERE id = ?").run(playlistId); } + +// Slow queue table for yt-dlp playlist downloads +db.run(` + CREATE TABLE IF NOT EXISTS slow_queue ( + id TEXT PRIMARY KEY, + url TEXT NOT NULL, + title TEXT NOT NULL, + user_id INTEGER NOT NULL, + status TEXT DEFAULT 'queued', + progress REAL DEFAULT 0, + error TEXT, + playlist_id TEXT, + playlist_name TEXT, + position INTEGER, + created_at INTEGER, + completed_at INTEGER, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE SET NULL + ) +`); + +db.run(`CREATE INDEX IF NOT EXISTS idx_slow_queue_user ON slow_queue(user_id)`); +db.run(`CREATE INDEX IF NOT EXISTS idx_slow_queue_status ON slow_queue(status)`); + +// Slow queue types +export interface SlowQueueRow { + id: string; + url: string; + title: string; + user_id: number; + status: string; + progress: number; + error: string | null; + playlist_id: string | null; + playlist_name: string | null; + position: number | null; + created_at: number; + completed_at: number | null; +} + +// Slow queue CRUD functions +export function saveSlowQueueItem(item: { + id: string; + url: string; + title: string; + userId: number; + status: string; + progress: number; + error?: string; + playlistId?: string; + playlistName?: string; + position?: number; + createdAt: number; + completedAt?: number; +}): void { + db.query(` + INSERT INTO slow_queue (id, url, title, user_id, status, progress, error, playlist_id, playlist_name, position, created_at, completed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + status = excluded.status, + progress = excluded.progress, + error = excluded.error, + completed_at = excluded.completed_at + `).run( + item.id, + item.url, + item.title, + item.userId, + item.status, + item.progress, + item.error ?? null, + item.playlistId ?? null, + item.playlistName ?? null, + item.position ?? null, + item.createdAt, + item.completedAt ?? null + ); +} + +export function updateSlowQueueItem(id: string, updates: { + status?: string; + progress?: number; + error?: string; + completedAt?: number; +}): void { + const sets: string[] = []; + const values: any[] = []; + + if (updates.status !== undefined) { + sets.push("status = ?"); + values.push(updates.status); + } + if (updates.progress !== undefined) { + sets.push("progress = ?"); + values.push(updates.progress); + } + if (updates.error !== undefined) { + sets.push("error = ?"); + values.push(updates.error); + } + if (updates.completedAt !== undefined) { + sets.push("completed_at = ?"); + values.push(updates.completedAt); + } + + if (sets.length === 0) return; + + values.push(id); + db.query(`UPDATE slow_queue SET ${sets.join(", ")} WHERE id = ?`).run(...values); +} + +export function loadSlowQueue(): SlowQueueRow[] { + return db.query( + "SELECT * FROM slow_queue WHERE status IN ('queued', 'downloading') ORDER BY created_at" + ).all() as SlowQueueRow[]; +} + +export function deleteSlowQueueItem(id: string): void { + db.query("DELETE FROM slow_queue WHERE id = ?").run(id); +} + +export function clearCompletedSlowQueue(maxAge: number = 3600): void { + const cutoff = Math.floor(Date.now() / 1000) - maxAge; + db.query( + "DELETE FROM slow_queue WHERE status IN ('complete', 'error', 'cancelled') AND completed_at < ?" + ).run(cutoff); +} + +export function getSlowQueueByUser(userId: number): SlowQueueRow[] { + return db.query( + "SELECT * FROM slow_queue WHERE user_id = ? ORDER BY created_at" + ).all(userId) as SlowQueueRow[]; +} + +export function playlistNameExists(name: string, userId: number): boolean { + const result = db.query( + "SELECT 1 FROM playlists WHERE name = ? AND owner_id = ? LIMIT 1" + ).get(name, userId); + return !!result; +} + +export function generateUniquePlaylistName(baseName: string, userId: number): string { + if (!playlistNameExists(baseName, userId)) { + return baseName; + } + + let counter = 2; + while (playlistNameExists(`${baseName} (${counter})`, userId)) { + counter++; + } + return `${baseName} (${counter})`; +} diff --git a/init.ts b/init.ts index c4727aa..e7cc79f 100644 --- a/init.ts +++ b/init.ts @@ -8,6 +8,7 @@ import { saveChannelQueue, loadChannelQueue, removeTrackFromQueues, + addTracksToPlaylist, } from "./db"; import { config, MUSIC_DIR, DEFAULT_CONFIG } from "./config"; import { state, setLibrary } from "./state"; @@ -15,6 +16,8 @@ import { broadcastToAll, broadcastChannelList, sendToUser } from "./broadcast"; import { initYtdlp, setProgressCallback, + setTrackReadyCallback, + type QueueItem, } from "./ytdlp"; // Auto-discover tracks if queue is empty @@ -98,6 +101,8 @@ export async function init(): Promise { status: item.status, progress: item.progress, queueType: item.queueType, + playlistId: item.playlistId, + playlistName: item.playlistName, error: item.error }); }); @@ -106,6 +111,23 @@ export async function init(): Promise { const library = new Library(MUSIC_DIR); setLibrary(library); + // Track pending playlist additions (title -> {playlistId, playlistName, userId}) + const pendingPlaylistTracks = new Map(); + + // When a download completes, register it for playlist addition + setTrackReadyCallback((item: QueueItem) => { + if (!item.playlistId) return; + + // Store the pending addition - will be processed when library detects the file + // yt-dlp saves files as "title.mp3", so use the title as key + pendingPlaylistTracks.set(item.title.toLowerCase(), { + playlistId: item.playlistId, + playlistName: item.playlistName!, + userId: item.userId + }); + console.log(`[ytdlp] Registered pending playlist addition: "${item.title}" → ${item.playlistName}`); + }); + // Scan library first await library.scan(); library.startWatching(); @@ -115,10 +137,69 @@ export async function init(): Promise { broadcastToAll({ type: "scan_progress", scanning: false }); }); + // Normalize string for matching (handle Windows filename character substitutions) + function normalizeForMatch(s: string): string { + return s.toLowerCase() + .replace(/|/g, "|") // fullwidth vertical line → pipe + .replace(/"/g, '"') // fullwidth quotation + .replace(/*/g, "*") // fullwidth asterisk + .replace(/?/g, "?") // fullwidth question mark + .replace(/</g, "<") // fullwidth less-than + .replace(/>/g, ">") // fullwidth greater-than + .replace(/:/g, ":") // fullwidth colon + .replace(///g, "/") // fullwidth slash + .replace(/\/g, "\\") // fullwidth backslash + .trim(); + } + + // Helper to check if track matches a pending playlist addition + function checkPendingPlaylistAddition(track: { id: string; title?: string; filename?: string }) { + if (pendingPlaylistTracks.size === 0) return; + + const trackTitle = normalizeForMatch(track.title || ""); + const trackFilename = normalizeForMatch((track.filename || "").replace(/\.[^.]+$/, "")); // Remove extension + + console.log(`[ytdlp] Checking track against ${pendingPlaylistTracks.size} pending: title="${trackTitle}" filename="${trackFilename}"`); + + for (const [pendingTitle, pending] of pendingPlaylistTracks) { + const normalizedPending = normalizeForMatch(pendingTitle); + + // Match by title or filename (yt-dlp uses title as filename) + 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)); + + console.log(`[ytdlp] vs pending="${normalizedPending}" → ${matches ? "MATCH" : "no match"}`); + + if (matches) { + console.log(`[ytdlp] Adding track ${track.id} to playlist ${pending.playlistId}`); + try { + addTracksToPlaylist(pending.playlistId, [track.id]); + sendToUser(pending.userId, { + type: "toast", + message: `Added to playlist: ${pending.playlistName}`, + toastType: "info" + }); + pendingPlaylistTracks.delete(pendingTitle); + } catch (e) { + console.error(`[ytdlp] Failed to add track to playlist:`, e); + } + return; + } + } + } + // Broadcast when tracks are added/updated library.on("added", (track) => { broadcastToAll({ type: "toast", message: `Added: ${track.title || track.filename}`, toastType: "info" }); library.logActivity("scan_added", { id: track.id, filename: track.filename, title: track.title }); + + // Check if this track was pending playlist addition (defer to ensure DB is updated) + setTimeout(() => checkPendingPlaylistAddition(track), 100); }); library.on("changed", (track) => { broadcastToAll({ type: "toast", message: `Updated: ${track.title || track.filename}`, toastType: "info" }); diff --git a/public/styles.css b/public/styles.css index ab62b2a..61b1c62 100644 --- a/public/styles.css +++ b/public/styles.css @@ -75,11 +75,16 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe .slow-queue-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.3rem; padding: 0 0.2rem; } .slow-queue-title { font-size: 0.75rem; color: #888; font-weight: 500; } .slow-queue-timer { font-size: 0.7rem; color: #6af; } -.slow-queue-list { display: flex; flex-direction: column; gap: 0.15rem; max-height: 150px; overflow-y: auto; } +.slow-queue-list { display: flex; flex-direction: column; gap: 0.15rem; max-height: 200px; overflow-y: auto; } +.slow-queue-playlist-header { font-size: 0.7rem; color: #888; padding: 0.3rem 0.2rem 0.15rem; margin-top: 0.2rem; border-top: 1px solid #2a2a2a; } +.slow-queue-playlist-header:first-child { border-top: none; margin-top: 0; } .slow-queue-item { display: flex; align-items: center; gap: 0.4rem; padding: 0.25rem 0.4rem; background: #1a1a2a; border-radius: 3px; font-size: 0.75rem; color: #6af; } .slow-queue-item.next { background: #1a2a2a; color: #4cf; } .slow-queue-item-icon { flex-shrink: 0; font-size: 0.7rem; } .slow-queue-item-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.slow-queue-cancel { background: none; border: none; color: #666; cursor: pointer; padding: 0 0.2rem; font-size: 0.7rem; opacity: 0; transition: opacity 0.15s; } +.slow-queue-item:hover .slow-queue-cancel { opacity: 1; } +.slow-queue-cancel:hover { color: #e44; } .scan-progress { font-size: 0.7rem; color: #ea4; padding: 0.2rem 0.4rem; background: #2a2a1a; border-radius: 3px; margin-bottom: 0.3rem; display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; } .scan-progress.hidden { display: none; } .scan-progress.complete { color: #4e8; background: #1a2a1a; } diff --git a/public/upload.js b/public/upload.js index 03c3645..45d4854 100644 --- a/public/upload.js +++ b/public/upload.js @@ -107,20 +107,23 @@ if (data.type === "playlist") { // Ask user to confirm playlist download - const confirmed = confirm(`Download playlist "${data.title}" with ${data.count} items?\n\nItems will be downloaded slowly (one every ~3 minutes) to avoid overloading the server.`); + const confirmed = confirm(`Download playlist "${data.title}" with ${data.count} items?\n\nItems will be downloaded slowly (one every ~3 minutes) to avoid overloading the server.\n\nA playlist will be created automatically.`); if (confirmed) { - // Confirm playlist download + // Confirm playlist download with title for auto-playlist creation const confirmRes = await fetch("/api/fetch/confirm", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ items: data.items }) + body: JSON.stringify({ items: data.items, playlistTitle: data.title }) }); if (confirmRes.ok) { const confirmData = await confirmRes.json(); - M.showToast(confirmData.message); - // Tasks will be created by WebSocket progress messages + M.showToast(`${confirmData.message} → "${confirmData.playlistName}"`); + // Refresh playlists to show the new one + if (M.playlists?.load) M.playlists.load(); + // Refresh slow queue display + pollSlowQueue(); } else { const err = await confirmRes.json().catch(() => ({})); M.showToast(err.error || "Failed to queue playlist", "error"); @@ -252,14 +255,57 @@ const timerEl = section.querySelector(".slow-queue-timer"); timerEl.textContent = `${queuedItems.length} queued · next in ${formatTime(slowQueueNextIn)}`; + // Group items by playlist + const byPlaylist = new Map(); + for (const item of queuedItems) { + const key = item.playlistId || "__none__"; + if (!byPlaylist.has(key)) { + byPlaylist.set(key, { name: item.playlistName, items: [] }); + } + byPlaylist.get(key).items.push(item); + } + // Update list const listEl = section.querySelector(".slow-queue-list"); - listEl.innerHTML = queuedItems.map((item, i) => ` -
- ${i === 0 ? '⏳' : '·'} - ${item.title} -
- `).join(""); + let html = ""; + + for (const [playlistId, group] of byPlaylist) { + if (group.name) { + html += `
📁 ${group.name}
`; + } + html += group.items.map((item, i) => { + const isNext = queuedItems.indexOf(item) === 0; + return ` +
+ ${isNext ? '⏳' : '·'} + ${item.title} + +
+ `; + }).join(""); + } + + listEl.innerHTML = html; + + // Add cancel handlers + listEl.querySelectorAll(".slow-queue-cancel").forEach(btn => { + btn.onclick = async (e) => { + e.stopPropagation(); + const itemEl = btn.closest(".slow-queue-item"); + const itemId = itemEl.dataset.id; + try { + const res = await fetch(`/api/fetch/${itemId}`, { method: "DELETE" }); + if (res.ok) { + itemEl.remove(); + pollSlowQueue(); + } else { + M.showToast("Cannot cancel item", "error"); + } + } catch (e) { + M.showToast("Failed to cancel", "error"); + } + }; + }); updateTasksEmpty(); } diff --git a/routes/fetch.ts b/routes/fetch.ts index a53b632..7597bb2 100644 --- a/routes/fetch.ts +++ b/routes/fetch.ts @@ -5,8 +5,10 @@ import { addToFastQueue, addToSlowQueue, getUserQueues, + cancelSlowQueueItem, } from "../ytdlp"; import { getOrCreateUser } from "./helpers"; +import { createPlaylist, generateUniquePlaylistName } from "../db"; const ytdlpConfig = config.ytdlp || DEFAULT_CONFIG.ytdlp!; @@ -72,12 +74,19 @@ export async function handleFetchConfirm(req: Request, server: any): Promise ({ id: i.id, title: i.title })) }, { headers }); } catch (e) { @@ -107,3 +118,18 @@ export function handleGetFetchQueue(req: Request, server: any): Response { const queues = getUserQueues(user.id); return Response.json(queues, { headers }); } + +// DELETE /api/fetch/:id - cancel a slow queue item +export function handleCancelFetchItem(req: Request, server: any, itemId: string): Response { + const { user, headers } = getOrCreateUser(req, server); + if (!user) { + return Response.json({ error: "Authentication required" }, { status: 401 }); + } + + const success = cancelSlowQueueItem(itemId, user.id); + if (success) { + return Response.json({ message: "Item cancelled" }, { headers }); + } else { + return Response.json({ error: "Cannot cancel item (not found, not owned, or already downloading)" }, { status: 400, headers }); + } +} diff --git a/routes/index.ts b/routes/index.ts index 67505fa..08a0cf8 100644 --- a/routes/index.ts +++ b/routes/index.ts @@ -40,6 +40,7 @@ import { handleFetch, handleFetchConfirm, handleGetFetchQueue, + handleCancelFetchItem, } from "./fetch"; // Playlist routes @@ -149,6 +150,10 @@ export function createRouter() { if (path === "/api/fetch" && req.method === "GET") { return handleGetFetchQueue(req, server); } + const fetchCancelMatch = path.match(/^\/api\/fetch\/([^/]+)$/); + if (fetchCancelMatch && req.method === "DELETE") { + return handleCancelFetchItem(req, server, fetchCancelMatch[1]); + } // Playlist routes if (path === "/api/playlists" && req.method === "GET") { diff --git a/ytdlp.ts b/ytdlp.ts index 880edc3..2fad06e 100644 --- a/ytdlp.ts +++ b/ytdlp.ts @@ -3,19 +3,32 @@ import { spawn } from "child_process"; import { join } from "path"; +import { + saveSlowQueueItem, + updateSlowQueueItem, + loadSlowQueue, + deleteSlowQueueItem, + clearCompletedSlowQueue, + addTracksToPlaylist, + type SlowQueueRow +} from "./db"; export interface QueueItem { id: string; url: string; title: string; userId: number; - status: "queued" | "downloading" | "complete" | "error"; + status: "queued" | "downloading" | "complete" | "error" | "cancelled"; progress: number; queueType: "fast" | "slow"; error?: string; filename?: string; createdAt: number; completedAt?: number; + playlistId?: string; + playlistName?: string; + position?: number; + trackId?: string; // Set after successful download } export interface YtdlpStatus { @@ -65,6 +78,7 @@ let lastSlowDownload = 0; // Callbacks let onProgress: ProgressCallback | null = null; +let onTrackReady: ((item: QueueItem) => void) | null = null; // Generate unique ID function generateId(): string { @@ -115,6 +129,17 @@ export async function initYtdlp(config: { ffmpegAvailable = false; } + // Load persisted slow queue from database + if (featureEnabled) { + const savedQueue = loadSlowQueue(); + for (const row of savedQueue) { + slowQueue.push(rowToQueueItem(row)); + } + if (savedQueue.length > 0) { + console.log(`[ytdlp] Restored ${savedQueue.length} items from slow queue`); + } + } + // Start slow queue processor if (featureEnabled) { startSlowQueueProcessor(); @@ -123,6 +148,25 @@ export async function initYtdlp(config: { return getStatus(); } +// Convert database row to QueueItem +function rowToQueueItem(row: SlowQueueRow): QueueItem { + return { + id: row.id, + url: row.url, + title: row.title, + userId: row.user_id, + status: row.status as QueueItem["status"], + progress: row.progress, + queueType: "slow", + error: row.error ?? undefined, + createdAt: row.created_at * 1000, + completedAt: row.completed_at ? row.completed_at * 1000 : undefined, + playlistId: row.playlist_id ?? undefined, + playlistName: row.playlist_name ?? undefined, + position: row.position ?? undefined + }; +} + // Run a command and return stdout function runCommand(cmd: string, args: string[]): Promise { const fullCmd = `${cmd} ${args.join(" ")}`; @@ -162,6 +206,11 @@ export function setProgressCallback(callback: ProgressCallback): void { onProgress = callback; } +// Set track ready callback (called when download completes and needs playlist association) +export function setTrackReadyCallback(callback: (item: QueueItem) => void): void { + onTrackReady = callback; +} + // Get all queue items export function getQueues(): { fastQueue: QueueItem[]; slowQueue: QueueItem[]; slowQueueNextIn: number } { const now = Date.now(); @@ -258,8 +307,13 @@ export function addToFastQueue(url: string, title: string, userId: number): Queu } // Add items to slow queue (for playlists) -export function addToSlowQueue(items: { url: string; title: string }[], userId: number): QueueItem[] { - const queueItems: QueueItem[] = items.map(item => ({ +export function addToSlowQueue( + items: { url: string; title: string }[], + userId: number, + playlist?: { id: string; name: string } +): QueueItem[] { + const now = Date.now(); + const queueItems: QueueItem[] = items.map((item, index) => ({ id: generateId(), url: item.url, title: item.title, @@ -267,8 +321,28 @@ export function addToSlowQueue(items: { url: string; title: string }[], userId: status: "queued" as const, progress: 0, queueType: "slow" as const, - createdAt: Date.now() + createdAt: now, + playlistId: playlist?.id, + playlistName: playlist?.name, + position: playlist ? index : undefined })); + + // Persist to database + for (const item of queueItems) { + saveSlowQueueItem({ + id: item.id, + url: item.url, + title: item.title, + userId: item.userId, + status: item.status, + progress: item.progress, + playlistId: item.playlistId, + playlistName: item.playlistName, + position: item.position, + createdAt: Math.floor(item.createdAt / 1000) + }); + } + slowQueue.push(...queueItems); return queueItems; } @@ -366,6 +440,21 @@ async function downloadItem(item: QueueItem): Promise { item.status = "complete"; item.progress = 100; item.completedAt = Date.now(); + + // Update database + if (item.queueType === "slow") { + updateSlowQueueItem(item.id, { + status: "complete", + progress: 100, + completedAt: Math.floor(item.completedAt / 1000) + }); + + // Register for playlist addition immediately - library will match when it scans + if (item.playlistId && onTrackReady) { + onTrackReady(item); + } + } + notifyProgress(item); // Remove from queue after delay @@ -374,6 +463,17 @@ async function downloadItem(item: QueueItem): Promise { } catch (e: any) { item.status = "error"; item.error = e.message || "Download failed"; + item.completedAt = Date.now(); + + // Update database + if (item.queueType === "slow") { + updateSlowQueueItem(item.id, { + status: "error", + error: item.error, + completedAt: Math.floor(item.completedAt / 1000) + }); + } + notifyProgress(item); // Remove from queue after delay @@ -389,9 +489,35 @@ function removeFromQueue(item: QueueItem): void { } else { const idx = slowQueue.findIndex(i => i.id === item.id); if (idx !== -1) slowQueue.splice(idx, 1); + // Remove from database + deleteSlowQueueItem(item.id); } } +// Cancel a slow queue item +export function cancelSlowQueueItem(id: string, userId: number): boolean { + const item = slowQueue.find(i => i.id === id && i.userId === userId); + if (!item || item.status === "downloading") { + return false; // Can't cancel if not found, not owned, or already downloading + } + + item.status = "cancelled"; + item.completedAt = Date.now(); + + // Update database + updateSlowQueueItem(id, { + status: "cancelled", + completedAt: Math.floor(item.completedAt / 1000) + }); + + notifyProgress(item); + + // Remove from queue after brief delay + setTimeout(() => removeFromQueue(item), 1000); + + return true; +} + // Notify progress callback function notifyProgress(item: QueueItem): void { if (onProgress) { @@ -405,7 +531,7 @@ export function cleanupOldItems(maxAge: number = 3600000): void { const cleanup = (queue: QueueItem[]) => { for (let i = queue.length - 1; i >= 0; i--) { const item = queue[i]; - if ((item.status === "complete" || item.status === "error") && + if ((item.status === "complete" || item.status === "error" || item.status === "cancelled") && now - item.createdAt > maxAge) { queue.splice(i, 1); } @@ -413,4 +539,7 @@ export function cleanupOldItems(maxAge: number = 3600000): void { }; cleanup(fastQueue); cleanup(slowQueue); + + // Also cleanup database + clearCompletedSlowQueue(Math.floor(maxAge / 1000)); }