dev/playlists #13
152
db.ts
152
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})`;
|
||||
}
|
||||
|
|
|
|||
81
init.ts
81
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<void> {
|
|||
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<void> {
|
|||
const library = new Library(MUSIC_DIR);
|
||||
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
|
||||
await library.scan();
|
||||
library.startWatching();
|
||||
|
|
@ -115,10 +137,69 @@ export async function init(): Promise<void> {
|
|||
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" });
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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) => `
|
||||
<div class="slow-queue-item${i === 0 ? ' next' : ''}">
|
||||
<span class="slow-queue-item-icon">${i === 0 ? '⏳' : '·'}</span>
|
||||
<span class="slow-queue-item-title">${item.title}</span>
|
||||
</div>
|
||||
`).join("");
|
||||
let html = "";
|
||||
|
||||
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>
|
||||
<button class="slow-queue-cancel" title="Cancel">✕</button>
|
||||
</div>
|
||||
`;
|
||||
}).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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Res
|
|||
}
|
||||
|
||||
try {
|
||||
const { items } = await req.json();
|
||||
const { items, playlistTitle } = await req.json();
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
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 hours = Math.floor(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`,
|
||||
queueType: "slow",
|
||||
estimatedTime,
|
||||
playlistId: playlist.id,
|
||||
playlistName: uniqueName,
|
||||
items: queueItems.map(i => ({ 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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
139
ytdlp.ts
139
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<string> {
|
||||
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<void> {
|
|||
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<void> {
|
|||
} 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));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue