255 lines
7.7 KiB
TypeScript
255 lines
7.7 KiB
TypeScript
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<string[]> {
|
|
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<void> {
|
|
// 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);
|
|
}
|