import { readdir } from "fs/promises"; import { Channel, type Track, type PersistenceCallback } from "./channel"; import { Library } from "./library"; import { saveChannel, updateChannelState, loadAllChannels, saveChannelQueue, loadChannelQueue, removeTrackFromQueues, } from "./db"; import { config, MUSIC_DIR, DEFAULT_CONFIG } from "./config"; import { state, setLibrary } from "./state"; import { broadcastToAll, broadcastChannelList, sendToUser } from "./broadcast"; import { initYtdlp, setProgressCallback, } 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, error: item.error }); }); // Initialize library const library = new Library(MUSIC_DIR); setLibrary(library); // Scan library first await library.scan(); library.startWatching(); // Broadcast when scan completes library.onScanComplete(() => { broadcastToAll({ type: "scan_progress", scanning: false }); }); // 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 }); }); 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 }); }); // 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); }