import { readdir } from "fs/promises"; import { Channel, type Track, type PersistenceCallback } from "./channel"; import { Library } from "./library"; import { saveChannel, updateChannelState, loadAllChannels, saveChannelQueue, loadChannelQueue, removeTrackFromQueues, addTracksToPlaylist, } from "./db"; import { config, MUSIC_DIR, DEFAULT_CONFIG } from "./config"; import { state, setLibrary } from "./state"; import { broadcastToAll, broadcastChannelList, sendToUser } from "./broadcast"; import { initYtdlp, setProgressCallback, setTrackReadyCallback, skipSlowQueueItem, getQueuedSlowItems, type QueueItem, } from "./ytdlp"; // Auto-discover tracks if queue is empty export async function discoverTracks(): Promise { try { const files = await readdir(MUSIC_DIR); return files.filter((f) => /\.(mp3|ogg|flac|wav|m4a|aac)$/i.test(f)).sort(); } catch { return []; } } // Generate unique channel ID export function generateChannelId(): string { return Math.random().toString(36).slice(2, 10); } // Persistence callback for channels const persistChannel: PersistenceCallback = (channel, type) => { if (type === "state") { updateChannelState(channel.id, { currentIndex: channel.currentIndex, startedAt: channel.startedAt, paused: channel.paused, pausedAt: channel.pausedAt, playbackMode: channel.playbackMode, }); } else if (type === "queue") { saveChannelQueue(channel.id, channel.queue.map(t => t.id)); } }; // Helper to build Track objects from track IDs using library export function buildTracksFromIds(trackIds: string[], lib: Library): Track[] { const tracks: Track[] = []; for (const tid of trackIds) { const libTrack = lib.getTrack(tid); if (libTrack && libTrack.duration > 0) { tracks.push({ id: libTrack.id, filename: libTrack.filename, title: libTrack.title || libTrack.filename.replace(/\.[^.]+$/, ""), duration: libTrack.duration, }); } } return tracks; } // Helper to get all library tracks as Track objects export function getAllLibraryTracks(lib: Library): Track[] { return lib.getAllTracks() .filter(t => t.duration > 0) .map(t => ({ id: t.id, filename: t.filename, title: t.title || t.filename.replace(/\.[^.]+$/, ""), duration: t.duration, })); } export async function init(): Promise { // Initialize yt-dlp if configured const ytdlpConfig = config.ytdlp || DEFAULT_CONFIG.ytdlp!; await initYtdlp({ enabled: ytdlpConfig.enabled, command: ytdlpConfig.command, ffmpegCommand: ytdlpConfig.ffmpegCommand, musicDir: MUSIC_DIR, fastQueueConcurrent: ytdlpConfig.fastQueueConcurrent, slowQueueInterval: ytdlpConfig.slowQueueInterval, allowPlaylists: ytdlpConfig.allowPlaylists }); // Set up ytdlp progress callback setProgressCallback((item) => { sendToUser(item.userId, { type: `fetch_${item.status === "downloading" ? "progress" : item.status}`, id: item.id, title: item.title, status: item.status, progress: item.progress, queueType: item.queueType, playlistId: item.playlistId, playlistName: item.playlistName, error: item.error }); }); // Initialize library const library = new Library(MUSIC_DIR); setLibrary(library); // Track pending playlist additions (title -> {playlistId, playlistName, userId}) const pendingPlaylistTracks = new Map(); // 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(); // Broadcast when scan completes library.onScanComplete(() => { 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 // Skip if both title and filename are too short if ((!trackTitle || trackTitle.length < 5) && (!trackFilename || trackFilename.length < 5)) return; console.log(`[ytdlp] Checking track against ${pendingPlaylistTracks.size} pending: title="${trackTitle}" filename="${trackFilename}"`); for (const [pendingTitle, pending] of pendingPlaylistTracks) { const normalizedPending = normalizeForMatch(pendingTitle); // Skip if pending title is too short if (!normalizedPending || normalizedPending.length < 5) continue; // Match by title or filename (yt-dlp uses title as filename) // Require exact match or very high overlap const matches = (trackTitle && trackTitle === normalizedPending) || (trackFilename && trackFilename === normalizedPending) || (trackTitle && normalizedPending && trackTitle.includes(normalizedPending) && normalizedPending.length >= trackTitle.length * 0.8) || (trackTitle && normalizedPending && normalizedPending.includes(trackTitle) && trackTitle.length >= normalizedPending.length * 0.8) || (trackFilename && normalizedPending && trackFilename.includes(normalizedPending) && normalizedPending.length >= trackFilename.length * 0.8) || (trackFilename && normalizedPending && normalizedPending.includes(trackFilename) && trackFilename.length >= normalizedPending.length * 0.8); 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" }); library.logActivity("scan_updated", { id: track.id, filename: track.filename, title: track.title }); }); // Prescan slow queue to find tracks already in library function prescanSlowQueue() { const queuedItems = getQueuedSlowItems(); if (queuedItems.length === 0) return; const tracks = library.getAllTracks(); if (tracks.length === 0) return; for (const item of queuedItems) { const itemTitle = normalizeForMatch(item.title); // Skip if title is too short (avoid false matches) if (!itemTitle || itemTitle.length < 5) continue; // Check if any library track matches for (const track of tracks) { const trackTitle = normalizeForMatch(track.title || ""); const trackFilename = normalizeForMatch((track.filename || "").replace(/\.[^.]+$/, "")); // Skip if both track title and filename are too short if ((!trackTitle || trackTitle.length < 5) && (!trackFilename || trackFilename.length < 5)) continue; // Require exact match or very high overlap (not just substring) const matches = (trackTitle && trackTitle === itemTitle) || (trackFilename && trackFilename === itemTitle) || // Only allow includes if the shorter string is at least 80% of the longer (trackTitle && itemTitle && trackTitle.includes(itemTitle) && itemTitle.length >= trackTitle.length * 0.8) || (trackTitle && itemTitle && itemTitle.includes(trackTitle) && trackTitle.length >= itemTitle.length * 0.8) || (trackFilename && itemTitle && trackFilename.includes(itemTitle) && itemTitle.length >= trackFilename.length * 0.8) || (trackFilename && itemTitle && itemTitle.includes(trackFilename) && trackFilename.length >= itemTitle.length * 0.8); if (matches) { console.log(`[ytdlp] Prescan: "${item.title}" already exists as "${track.title || track.filename}"`); // Skip download and add to playlist const skipped = skipSlowQueueItem(item.id, track.id); if (skipped && skipped.playlistId) { try { addTracksToPlaylist(skipped.playlistId, [track.id]); sendToUser(skipped.userId, { type: "toast", message: `Already in library, added to: ${skipped.playlistName}`, toastType: "info" }); } catch (e) { console.error(`[ytdlp] Failed to add existing track to playlist:`, e); } } break; } } } } // Run prescan periodically (every 30 seconds) setInterval(prescanSlowQueue, 30000); // Also run once after initial scan completes library.onScanComplete(() => { setTimeout(prescanSlowQueue, 1000); }); // Load channels from database const savedChannels = loadAllChannels(); let hasDefault = false; for (const row of savedChannels) { const trackIds = loadChannelQueue(row.id); const tracks = buildTracksFromIds(trackIds, library); const isDefault = row.is_default === 1; if (isDefault) { hasDefault = true; } const channelTracks = (isDefault && tracks.length === 0) ? getAllLibraryTracks(library) : tracks; const channel = new Channel({ id: row.id, name: row.name, description: row.description, tracks: channelTracks, createdBy: row.created_by, isDefault, currentIndex: row.current_index, startedAt: row.started_at, paused: row.paused === 1, pausedAt: row.paused_at, playbackMode: (row.playback_mode as "repeat-all" | "repeat-one" | "shuffle") || "repeat-all", }); channel.setPersistenceCallback(persistChannel); state.channels.set(row.id, channel); console.log(`Loaded channel "${row.name}" (id=${row.id}) with ${channelTracks.length} tracks`); } // Create default channel if it doesn't exist if (!hasDefault) { const tracks = getAllLibraryTracks(library); const defaultChannel = new Channel({ id: "main", name: "Main Channel", description: "All tracks from the library", tracks, isDefault: true, createdBy: null, }); defaultChannel.setPersistenceCallback(persistChannel); state.channels.set("main", defaultChannel); saveChannel({ id: defaultChannel.id, name: defaultChannel.name, description: defaultChannel.description, createdBy: defaultChannel.createdBy, isDefault: true, currentIndex: defaultChannel.currentIndex, startedAt: defaultChannel.startedAt, paused: defaultChannel.paused, pausedAt: defaultChannel.pausedAt, playbackMode: defaultChannel.playbackMode, }); saveChannelQueue(defaultChannel.id, tracks.map(t => t.id)); console.log(`Default channel created: ${tracks.length} tracks`); } // Listen for library changes and notify clients library.on("added", (track) => { console.log(`New track detected: ${track.title}`); const allTracks = library.getAllTracks().map(t => ({ id: t.id, title: t.title, duration: t.duration })); broadcastToAll({ type: "track_added", track: { id: track.id, title: track.title, duration: track.duration }, library: allTracks }); }); library.on("removed", (track) => { console.log(`Track removed: ${track.title}`); removeTrackFromQueues(track.id); const allTracks = library.getAllTracks().map(t => ({ id: t.id, title: t.title, duration: t.duration })); broadcastToAll({ type: "track_removed", track: { id: track.id, title: track.title }, library: allTracks }); }); // Tick interval: advance tracks when needed, broadcast every 30s let tickCount = 0; setInterval(() => { tickCount++; for (const channel of state.channels.values()) { const changed = channel.tick(); if (changed) { console.log(`[Tick] Channel "${channel.name}" advanced to track ${channel.currentIndex}`); } if (!changed && tickCount % 30 === 0) { console.log(`[Tick] Broadcasting state for channel "${channel.name}" (${channel.clients.size} clients)`); channel.broadcast(); } } // Broadcast scan progress every 2 seconds while scanning const scanProgress = library.scanProgress; if (scanProgress.scanning && tickCount % 2 === 0) { broadcastToAll({ type: "scan_progress", scanning: true, processed: scanProgress.processed, total: scanProgress.total }); } else if (!scanProgress.scanning && tickCount % 30 === 0) { broadcastToAll({ type: "scan_progress", scanning: false }); } }, 1000); }