// MusicRoom - yt-dlp integration module // Handles fetching audio from URLs via yt-dlp import { spawn } from "child_process"; import { join } from "path"; export interface QueueItem { id: string; url: string; title: string; userId: number; status: "queued" | "downloading" | "complete" | "error"; progress: number; queueType: "fast" | "slow"; error?: string; filename?: string; createdAt: number; completedAt?: number; } 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; // 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; } // Start slow queue processor if (featureEnabled) { startSlowQueueProcessor(); } return getStatus(); } // 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, { shell: true }); 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; } // 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): QueueItem[] { const queueItems: QueueItem[] = items.map(item => ({ id: generateId(), url: item.url, title: item.title, userId, status: "queued" as const, progress: 0, queueType: "slow" as const, createdAt: Date.now() })); 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, { shell: true }); 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(); notifyProgress(item); // Remove from queue after delay setTimeout(() => removeFromQueue(item), 5000); } catch (e: any) { item.status = "error"; item.error = e.message || "Download failed"; 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); } } // Notify progress callback function notifyProgress(item: QueueItem): void { if (onProgress) { onProgress(item); } } // 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") && now - item.createdAt > maxAge) { queue.splice(i, 1); } } }; cleanup(fastQueue); cleanup(slowQueue); }