persistent playlists and auto playlist

This commit is contained in:
peterino2 2026-02-06 23:45:57 -08:00
parent a89cc14448
commit 2803410a90
7 changed files with 463 additions and 19 deletions

152
db.ts
View File

@ -694,3 +694,155 @@ export function generatePlaylistShareToken(playlistId: string): string {
export function removePlaylistShareToken(playlistId: string): void { export function removePlaylistShareToken(playlistId: string): void {
db.query("UPDATE playlists SET share_token = NULL WHERE id = ?").run(playlistId); 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})`;
}

81
init.ts
View File

@ -8,6 +8,7 @@ import {
saveChannelQueue, saveChannelQueue,
loadChannelQueue, loadChannelQueue,
removeTrackFromQueues, removeTrackFromQueues,
addTracksToPlaylist,
} from "./db"; } from "./db";
import { config, MUSIC_DIR, DEFAULT_CONFIG } from "./config"; import { config, MUSIC_DIR, DEFAULT_CONFIG } from "./config";
import { state, setLibrary } from "./state"; import { state, setLibrary } from "./state";
@ -15,6 +16,8 @@ import { broadcastToAll, broadcastChannelList, sendToUser } from "./broadcast";
import { import {
initYtdlp, initYtdlp,
setProgressCallback, setProgressCallback,
setTrackReadyCallback,
type QueueItem,
} from "./ytdlp"; } from "./ytdlp";
// Auto-discover tracks if queue is empty // Auto-discover tracks if queue is empty
@ -98,6 +101,8 @@ export async function init(): Promise<void> {
status: item.status, status: item.status,
progress: item.progress, progress: item.progress,
queueType: item.queueType, queueType: item.queueType,
playlistId: item.playlistId,
playlistName: item.playlistName,
error: item.error error: item.error
}); });
}); });
@ -106,6 +111,23 @@ export async function init(): Promise<void> {
const library = new Library(MUSIC_DIR); const library = new Library(MUSIC_DIR);
setLibrary(library); setLibrary(library);
// Track pending playlist additions (title -> {playlistId, playlistName, userId})
const pendingPlaylistTracks = new Map<string, { playlistId: string; playlistName: string; userId: number }>();
// 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 // Scan library first
await library.scan(); await library.scan();
library.startWatching(); library.startWatching();
@ -115,10 +137,69 @@ export async function init(): Promise<void> {
broadcastToAll({ type: "scan_progress", scanning: false }); 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 // Broadcast when tracks are added/updated
library.on("added", (track) => { library.on("added", (track) => {
broadcastToAll({ type: "toast", message: `Added: ${track.title || track.filename}`, toastType: "info" }); broadcastToAll({ type: "toast", message: `Added: ${track.title || track.filename}`, toastType: "info" });
library.logActivity("scan_added", { id: track.id, filename: track.filename, title: track.title }); 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) => { library.on("changed", (track) => {
broadcastToAll({ type: "toast", message: `Updated: ${track.title || track.filename}`, toastType: "info" }); broadcastToAll({ type: "toast", message: `Updated: ${track.title || track.filename}`, toastType: "info" });

View File

@ -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-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-title { font-size: 0.75rem; color: #888; font-weight: 500; }
.slow-queue-timer { font-size: 0.7rem; color: #6af; } .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 { 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.next { background: #1a2a2a; color: #4cf; }
.slow-queue-item-icon { flex-shrink: 0; font-size: 0.7rem; } .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-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 { 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.hidden { display: none; }
.scan-progress.complete { color: #4e8; background: #1a2a1a; } .scan-progress.complete { color: #4e8; background: #1a2a1a; }

View File

@ -107,20 +107,23 @@
if (data.type === "playlist") { if (data.type === "playlist") {
// Ask user to confirm playlist download // 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) { if (confirmed) {
// Confirm playlist download // Confirm playlist download with title for auto-playlist creation
const confirmRes = await fetch("/api/fetch/confirm", { const confirmRes = await fetch("/api/fetch/confirm", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: data.items }) body: JSON.stringify({ items: data.items, playlistTitle: data.title })
}); });
if (confirmRes.ok) { if (confirmRes.ok) {
const confirmData = await confirmRes.json(); const confirmData = await confirmRes.json();
M.showToast(confirmData.message); M.showToast(`${confirmData.message} → "${confirmData.playlistName}"`);
// Tasks will be created by WebSocket progress messages // Refresh playlists to show the new one
if (M.playlists?.load) M.playlists.load();
// Refresh slow queue display
pollSlowQueue();
} else { } else {
const err = await confirmRes.json().catch(() => ({})); const err = await confirmRes.json().catch(() => ({}));
M.showToast(err.error || "Failed to queue playlist", "error"); M.showToast(err.error || "Failed to queue playlist", "error");
@ -252,14 +255,57 @@
const timerEl = section.querySelector(".slow-queue-timer"); const timerEl = section.querySelector(".slow-queue-timer");
timerEl.textContent = `${queuedItems.length} queued · next in ${formatTime(slowQueueNextIn)}`; 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 // Update list
const listEl = section.querySelector(".slow-queue-list"); const listEl = section.querySelector(".slow-queue-list");
listEl.innerHTML = queuedItems.map((item, i) => ` let html = "";
<div class="slow-queue-item${i === 0 ? ' next' : ''}">
<span class="slow-queue-item-icon">${i === 0 ? '⏳' : '·'}</span> for (const [playlistId, group] of byPlaylist) {
if (group.name) {
html += `<div class="slow-queue-playlist-header">📁 ${group.name}</div>`;
}
html += group.items.map((item, i) => {
const isNext = queuedItems.indexOf(item) === 0;
return `
<div class="slow-queue-item${isNext ? ' next' : ''}" data-id="${item.id}">
<span class="slow-queue-item-icon">${isNext ? '⏳' : '·'}</span>
<span class="slow-queue-item-title">${item.title}</span> <span class="slow-queue-item-title">${item.title}</span>
<button class="slow-queue-cancel" title="Cancel"></button>
</div> </div>
`).join(""); `;
}).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(); updateTasksEmpty();
} }

View File

@ -5,8 +5,10 @@ import {
addToFastQueue, addToFastQueue,
addToSlowQueue, addToSlowQueue,
getUserQueues, getUserQueues,
cancelSlowQueueItem,
} from "../ytdlp"; } from "../ytdlp";
import { getOrCreateUser } from "./helpers"; import { getOrCreateUser } from "./helpers";
import { createPlaylist, generateUniquePlaylistName } from "../db";
const ytdlpConfig = config.ytdlp || DEFAULT_CONFIG.ytdlp!; const ytdlpConfig = config.ytdlp || DEFAULT_CONFIG.ytdlp!;
@ -72,12 +74,19 @@ export async function handleFetchConfirm(req: Request, server: any): Promise<Res
} }
try { try {
const { items } = await req.json(); const { items, playlistTitle } = await req.json();
if (!Array.isArray(items) || items.length === 0) { if (!Array.isArray(items) || items.length === 0) {
return Response.json({ error: "Items required" }, { status: 400 }); return Response.json({ error: "Items required" }, { status: 400 });
} }
const queueItems = addToSlowQueue(items, user.id); // Auto-create playlist with unique name
const baseName = playlistTitle || "Imported Playlist";
const uniqueName = generateUniquePlaylistName(baseName, user.id);
const playlist = createPlaylist(uniqueName, user.id, `Imported from URL (${items.length} tracks)`);
console.log(`[Fetch] ${user.username} created playlist: ${uniqueName} (id=${playlist.id})`);
const queueItems = addToSlowQueue(items, user.id, { id: playlist.id, name: uniqueName });
const estimatedMinutes = Math.ceil(queueItems.length * ytdlpConfig.slowQueueInterval / 60); const estimatedMinutes = Math.ceil(queueItems.length * ytdlpConfig.slowQueueInterval / 60);
const hours = Math.floor(estimatedMinutes / 60); const hours = Math.floor(estimatedMinutes / 60);
const mins = estimatedMinutes % 60; const mins = estimatedMinutes % 60;
@ -89,6 +98,8 @@ export async function handleFetchConfirm(req: Request, server: any): Promise<Res
message: `Added ${queueItems.length} items to queue`, message: `Added ${queueItems.length} items to queue`,
queueType: "slow", queueType: "slow",
estimatedTime, estimatedTime,
playlistId: playlist.id,
playlistName: uniqueName,
items: queueItems.map(i => ({ id: i.id, title: i.title })) items: queueItems.map(i => ({ id: i.id, title: i.title }))
}, { headers }); }, { headers });
} catch (e) { } catch (e) {
@ -107,3 +118,18 @@ export function handleGetFetchQueue(req: Request, server: any): Response {
const queues = getUserQueues(user.id); const queues = getUserQueues(user.id);
return Response.json(queues, { headers }); 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 });
}
}

View File

@ -40,6 +40,7 @@ import {
handleFetch, handleFetch,
handleFetchConfirm, handleFetchConfirm,
handleGetFetchQueue, handleGetFetchQueue,
handleCancelFetchItem,
} from "./fetch"; } from "./fetch";
// Playlist routes // Playlist routes
@ -149,6 +150,10 @@ export function createRouter() {
if (path === "/api/fetch" && req.method === "GET") { if (path === "/api/fetch" && req.method === "GET") {
return handleGetFetchQueue(req, server); return handleGetFetchQueue(req, server);
} }
const fetchCancelMatch = path.match(/^\/api\/fetch\/([^/]+)$/);
if (fetchCancelMatch && req.method === "DELETE") {
return handleCancelFetchItem(req, server, fetchCancelMatch[1]);
}
// Playlist routes // Playlist routes
if (path === "/api/playlists" && req.method === "GET") { if (path === "/api/playlists" && req.method === "GET") {

139
ytdlp.ts
View File

@ -3,19 +3,32 @@
import { spawn } from "child_process"; import { spawn } from "child_process";
import { join } from "path"; import { join } from "path";
import {
saveSlowQueueItem,
updateSlowQueueItem,
loadSlowQueue,
deleteSlowQueueItem,
clearCompletedSlowQueue,
addTracksToPlaylist,
type SlowQueueRow
} from "./db";
export interface QueueItem { export interface QueueItem {
id: string; id: string;
url: string; url: string;
title: string; title: string;
userId: number; userId: number;
status: "queued" | "downloading" | "complete" | "error"; status: "queued" | "downloading" | "complete" | "error" | "cancelled";
progress: number; progress: number;
queueType: "fast" | "slow"; queueType: "fast" | "slow";
error?: string; error?: string;
filename?: string; filename?: string;
createdAt: number; createdAt: number;
completedAt?: number; completedAt?: number;
playlistId?: string;
playlistName?: string;
position?: number;
trackId?: string; // Set after successful download
} }
export interface YtdlpStatus { export interface YtdlpStatus {
@ -65,6 +78,7 @@ let lastSlowDownload = 0;
// Callbacks // Callbacks
let onProgress: ProgressCallback | null = null; let onProgress: ProgressCallback | null = null;
let onTrackReady: ((item: QueueItem) => void) | null = null;
// Generate unique ID // Generate unique ID
function generateId(): string { function generateId(): string {
@ -115,6 +129,17 @@ export async function initYtdlp(config: {
ffmpegAvailable = false; 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 // Start slow queue processor
if (featureEnabled) { if (featureEnabled) {
startSlowQueueProcessor(); startSlowQueueProcessor();
@ -123,6 +148,25 @@ export async function initYtdlp(config: {
return getStatus(); 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 // Run a command and return stdout
function runCommand(cmd: string, args: string[]): Promise<string> { function runCommand(cmd: string, args: string[]): Promise<string> {
const fullCmd = `${cmd} ${args.join(" ")}`; const fullCmd = `${cmd} ${args.join(" ")}`;
@ -162,6 +206,11 @@ export function setProgressCallback(callback: ProgressCallback): void {
onProgress = callback; 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 // Get all queue items
export function getQueues(): { fastQueue: QueueItem[]; slowQueue: QueueItem[]; slowQueueNextIn: number } { export function getQueues(): { fastQueue: QueueItem[]; slowQueue: QueueItem[]; slowQueueNextIn: number } {
const now = Date.now(); 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) // Add items to slow queue (for playlists)
export function addToSlowQueue(items: { url: string; title: string }[], userId: number): QueueItem[] { export function addToSlowQueue(
const queueItems: QueueItem[] = items.map(item => ({ 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(), id: generateId(),
url: item.url, url: item.url,
title: item.title, title: item.title,
@ -267,8 +321,28 @@ export function addToSlowQueue(items: { url: string; title: string }[], userId:
status: "queued" as const, status: "queued" as const,
progress: 0, progress: 0,
queueType: "slow" as const, 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); slowQueue.push(...queueItems);
return queueItems; return queueItems;
} }
@ -366,6 +440,21 @@ async function downloadItem(item: QueueItem): Promise<void> {
item.status = "complete"; item.status = "complete";
item.progress = 100; item.progress = 100;
item.completedAt = Date.now(); 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); notifyProgress(item);
// Remove from queue after delay // Remove from queue after delay
@ -374,6 +463,17 @@ async function downloadItem(item: QueueItem): Promise<void> {
} catch (e: any) { } catch (e: any) {
item.status = "error"; item.status = "error";
item.error = e.message || "Download failed"; 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); notifyProgress(item);
// Remove from queue after delay // Remove from queue after delay
@ -389,9 +489,35 @@ function removeFromQueue(item: QueueItem): void {
} else { } else {
const idx = slowQueue.findIndex(i => i.id === item.id); const idx = slowQueue.findIndex(i => i.id === item.id);
if (idx !== -1) slowQueue.splice(idx, 1); 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 // Notify progress callback
function notifyProgress(item: QueueItem): void { function notifyProgress(item: QueueItem): void {
if (onProgress) { if (onProgress) {
@ -405,7 +531,7 @@ export function cleanupOldItems(maxAge: number = 3600000): void {
const cleanup = (queue: QueueItem[]) => { const cleanup = (queue: QueueItem[]) => {
for (let i = queue.length - 1; i >= 0; i--) { for (let i = queue.length - 1; i >= 0; i--) {
const item = queue[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) { now - item.createdAt > maxAge) {
queue.splice(i, 1); queue.splice(i, 1);
} }
@ -413,4 +539,7 @@ export function cleanupOldItems(maxAge: number = 3600000): void {
}; };
cleanup(fastQueue); cleanup(fastQueue);
cleanup(slowQueue); cleanup(slowQueue);
// Also cleanup database
clearCompletedSlowQueue(Math.floor(maxAge / 1000));
} }