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 {
|
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
81
init.ts
|
|
@ -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" });
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
<span class="slow-queue-item-title">${item.title}</span>
|
if (group.name) {
|
||||||
</div>
|
html += `<div class="slow-queue-playlist-header">📁 ${group.name}</div>`;
|
||||||
`).join("");
|
}
|
||||||
|
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();
|
updateTasksEmpty();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
139
ytdlp.ts
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue