blastoise/init.ts

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);
}