// MusicRoom - yt-dlp integration module // Handles fetching audio from URLs via yt-dlp 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" | "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 { available: boolean; enabled: boolean; version: string | null; ffmpeg: boolean; } export interface PlaylistInfo { type: "playlist"; title: string; count: number; items: { id: string; url: string; title: string }[]; requiresConfirmation: true; } export interface SingleVideoInfo { type: "single"; id: string; title: string; url: string; } type ProgressCallback = (item: QueueItem) => void; // Configuration let ytdlpCommand = "yt-dlp"; let ffmpegCommand = "ffmpeg"; let musicDir = "./music"; let fastQueueConcurrent = 2; let slowQueueInterval = 180; let allowPlaylists = true; // Status let ytdlpAvailable = false; let ytdlpVersion: string | null = null; let ffmpegAvailable = false; let featureEnabled = false; // Queues const fastQueue: QueueItem[] = []; const slowQueue: QueueItem[] = []; let activeDownloads = 0; let slowQueueTimer: ReturnType | null = null; let lastSlowDownload = 0; // Callbacks let onProgress: ProgressCallback | null = null; let onTrackReady: ((item: QueueItem) => void) | null = null; // Generate unique ID function generateId(): string { return Math.random().toString(36).substring(2, 10); } // Initialize ytdlp module export async function initYtdlp(config: { enabled: boolean; command: string; ffmpegCommand: string; musicDir: string; fastQueueConcurrent: number; slowQueueInterval: number; allowPlaylists: boolean; }): Promise { featureEnabled = config.enabled; ytdlpCommand = config.command; ffmpegCommand = config.ffmpegCommand; musicDir = config.musicDir; fastQueueConcurrent = config.fastQueueConcurrent; slowQueueInterval = config.slowQueueInterval; allowPlaylists = config.allowPlaylists; if (!featureEnabled) { console.log("[ytdlp] Feature disabled in config"); return { available: false, enabled: false, version: null, ffmpeg: false }; } // Check yt-dlp availability try { ytdlpVersion = await runCommand(ytdlpCommand, ["--version"]); ytdlpAvailable = true; console.log(`[ytdlp] Found yt-dlp version: ${ytdlpVersion.trim()}`); } catch (e) { console.error(`[ytdlp] yt-dlp not found (command: ${ytdlpCommand})`); ytdlpAvailable = false; featureEnabled = false; } // Check ffmpeg availability try { await runCommand(ffmpegCommand, ["-version"]); ffmpegAvailable = true; console.log("[ytdlp] ffmpeg available"); } catch (e) { console.warn("[ytdlp] ffmpeg not found - audio extraction may fail"); 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(); } return getStatus(); } // Convert database row to QueueItem function rowToQueueItem(row: SlowQueueRow): QueueItem { return { id: row.id, url: row.url, title: row.title, userId: row.user_id, status: row.status as QueueItem["status"], progress: row.progress, queueType: "slow", error: row.error ?? undefined, createdAt: row.created_at * 1000, completedAt: row.completed_at ? row.completed_at * 1000 : undefined, playlistId: row.playlist_id ?? undefined, playlistName: row.playlist_name ?? undefined, position: row.position ?? undefined }; } // Run a command and return stdout function runCommand(cmd: string, args: string[]): Promise { const fullCmd = `${cmd} ${args.join(" ")}`; console.log(`[ytdlp] Running: ${fullCmd}`); return new Promise((resolve, reject) => { const proc = spawn(cmd, args); let stdout = ""; let stderr = ""; proc.stdout.on("data", (data) => { stdout += data; }); proc.stderr.on("data", (data) => { stderr += data; }); proc.on("close", (code) => { console.log(`[ytdlp] Command exited with code ${code}`); if (code === 0) resolve(stdout); else reject(new Error(stderr || `Exit code ${code}`)); }); proc.on("error", reject); }); } // Get current status export function getStatus(): YtdlpStatus { return { available: ytdlpAvailable, enabled: featureEnabled, version: ytdlpVersion, ffmpeg: ffmpegAvailable }; } // Check if feature is enabled and available export function isAvailable(): boolean { return featureEnabled && ytdlpAvailable; } // Set progress callback 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(); const nextIn = Math.max(0, Math.floor((lastSlowDownload + slowQueueInterval * 1000 - now) / 1000)); return { fastQueue: [...fastQueue], slowQueue: [...slowQueue], slowQueueNextIn: nextIn }; } // Get queue items for a specific user export function getUserQueues(userId: number): { fastQueue: QueueItem[]; slowQueue: QueueItem[]; slowQueueNextIn: number } { const queues = getQueues(); return { fastQueue: queues.fastQueue.filter(i => i.userId === userId), slowQueue: queues.slowQueue.filter(i => i.userId === userId), slowQueueNextIn: queues.slowQueueNextIn }; } // Check URL and detect if it's a playlist export async function checkUrl(url: string): Promise { const args = ["--flat-playlist", "--dump-json", "--no-warnings", url]; const output = await runCommand(ytdlpCommand, args); // Parse JSON lines const lines = output.trim().split("\n").filter(l => l); if (lines.length === 0) { throw new Error("No video found"); } if (lines.length === 1) { const data = JSON.parse(lines[0]); if (data._type === "playlist") { // It's a playlist with entries const items = (data.entries || []).map((e: any) => ({ id: generateId(), url: e.url || e.webpage_url || `https://youtube.com/watch?v=${e.id}`, title: e.title || "Unknown" })); return { type: "playlist", title: data.title || "Playlist", count: items.length, items, requiresConfirmation: true }; } else { // Single video return { type: "single", id: generateId(), title: data.title || "Unknown", url }; } } else { // Multiple JSON lines = playlist const items = lines.map(line => { const data = JSON.parse(line); return { id: generateId(), url: data.url || data.webpage_url || url, title: data.title || "Unknown" }; }); return { type: "playlist", title: "Playlist", count: items.length, items, requiresConfirmation: true }; } } // Add single video to fast queue export function addToFastQueue(url: string, title: string, userId: number): QueueItem { const item: QueueItem = { id: generateId(), url, title, userId, status: "queued", progress: 0, queueType: "fast", createdAt: Date.now() }; fastQueue.push(item); processNextFast(); return item; } // Add items to slow queue (for playlists) 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, userId, status: "queued" as const, progress: 0, queueType: "slow" as const, 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; } // Process next item in fast queue function processNextFast(): void { if (activeDownloads >= fastQueueConcurrent) return; const item = fastQueue.find(i => i.status === "queued"); if (!item) return; activeDownloads++; downloadItem(item).finally(() => { activeDownloads--; processNextFast(); }); } // Start slow queue processor function startSlowQueueProcessor(): void { if (slowQueueTimer) return; const processNext = () => { const item = slowQueue.find(i => i.status === "queued"); if (item) { lastSlowDownload = Date.now(); downloadItem(item).finally(() => { slowQueueTimer = setTimeout(processNext, slowQueueInterval * 1000); }); } else { slowQueueTimer = setTimeout(processNext, 5000); // Check again in 5s } }; // Start immediately if there are items const hasQueued = slowQueue.some(i => i.status === "queued"); if (hasQueued) { processNext(); } else { slowQueueTimer = setTimeout(processNext, 5000); } } // Download a single item async function downloadItem(item: QueueItem): Promise { item.status = "downloading"; item.progress = 0; notifyProgress(item); console.log(`[ytdlp] Starting download: ${item.title} (${item.url})`); try { const outputTemplate = join(musicDir, "%(title)s.%(ext)s"); const args = [ "-x", "--audio-format", "mp3", "-o", outputTemplate, "--progress", "--newline", "--no-warnings", item.url ]; const fullCmd = `${ytdlpCommand} ${args.join(" ")}`; console.log(`[ytdlp] Running: ${fullCmd}`); await new Promise((resolve, reject) => { const proc = spawn(ytdlpCommand, args); proc.stdout.on("data", (data) => { const line = data.toString(); console.log(`[ytdlp] ${line.trim()}`); // Parse progress from yt-dlp output const match = line.match(/(\d+\.?\d*)%/); if (match) { item.progress = parseFloat(match[1]); notifyProgress(item); } }); proc.stderr.on("data", (data) => { console.error(`[ytdlp] stderr: ${data}`); }); proc.on("close", (code) => { console.log(`[ytdlp] Download finished with code ${code}`); if (code === 0) resolve(); else reject(new Error(`yt-dlp exited with code ${code}`)); }); proc.on("error", reject); }); console.log(`[ytdlp] Complete: ${item.title}`); 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 setTimeout(() => removeFromQueue(item), 5000); } 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 setTimeout(() => removeFromQueue(item), 10000); } } // Remove item from queue function removeFromQueue(item: QueueItem): void { if (item.queueType === "fast") { const idx = fastQueue.findIndex(i => i.id === item.id); if (idx !== -1) fastQueue.splice(idx, 1); } 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; } // Cancel all queued items in slow queue for a user export function cancelAllSlowQueueItems(userId: number): number { const items = slowQueue.filter(i => i.userId === userId && i.status === "queued"); let cancelled = 0; for (const item of items) { item.status = "cancelled"; item.completedAt = Date.now(); updateSlowQueueItem(item.id, { status: "cancelled", completedAt: Math.floor(item.completedAt / 1000) }); notifyProgress(item); cancelled++; } // Remove all cancelled items after brief delay setTimeout(() => { for (let i = slowQueue.length - 1; i >= 0; i--) { if (slowQueue[i].status === "cancelled") { slowQueue.splice(i, 1); } } }, 1000); return cancelled; } // Notify progress callback function notifyProgress(item: QueueItem): void { if (onProgress) { onProgress(item); } } // Mark a slow queue item as skipped (already in library) export function skipSlowQueueItem(id: string, trackId: string): QueueItem | null { const item = slowQueue.find(i => i.id === id && i.status === "queued"); if (!item) return null; item.status = "complete"; item.progress = 100; item.completedAt = Date.now(); item.trackId = trackId; // Update database updateSlowQueueItem(id, { status: "complete", progress: 100, completedAt: Math.floor(item.completedAt / 1000) }); notifyProgress(item); // Remove from queue after brief delay setTimeout(() => removeFromQueue(item), 1000); return item; } // Get queued items from slow queue (for prescan) export function getQueuedSlowItems(): QueueItem[] { return slowQueue.filter(i => i.status === "queued"); } // Cleanup old completed/failed items export function cleanupOldItems(maxAge: number = 3600000): void { const now = Date.now(); const cleanup = (queue: QueueItem[]) => { for (let i = queue.length - 1; i >= 0; i--) { const item = queue[i]; if ((item.status === "complete" || item.status === "error" || item.status === "cancelled") && now - item.createdAt > maxAge) { queue.splice(i, 1); } } }; cleanup(fastQueue); cleanup(slowQueue); // Also cleanup database clearCompletedSlowQueue(Math.floor(maxAge / 1000)); }