336 lines
11 KiB
TypeScript
336 lines
11 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,
|
||
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,
|
||
type QueueItem,
|
||
} 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,
|
||
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<string, { playlistId: string; playlistName: string; userId: number }>();
|
||
|
||
// 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
|
||
|
||
console.log(`[ytdlp] Checking track against ${pendingPlaylistTracks.size} pending: title="${trackTitle}" filename="${trackFilename}"`);
|
||
|
||
for (const [pendingTitle, pending] of pendingPlaylistTracks) {
|
||
const normalizedPending = normalizeForMatch(pendingTitle);
|
||
|
||
// Match by title or filename (yt-dlp uses title as filename)
|
||
const matches =
|
||
(trackTitle && trackTitle === normalizedPending) ||
|
||
(trackFilename && trackFilename === normalizedPending) ||
|
||
(trackTitle && normalizedPending && trackTitle.includes(normalizedPending)) ||
|
||
(trackTitle && normalizedPending && normalizedPending.includes(trackTitle)) ||
|
||
(trackFilename && normalizedPending && trackFilename.includes(normalizedPending)) ||
|
||
(trackFilename && normalizedPending && normalizedPending.includes(trackFilename));
|
||
|
||
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 });
|
||
});
|
||
|
||
// 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);
|
||
}
|