blastoise/init.ts

392 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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 });
});
// 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);
// Check if any library track matches
for (const track of tracks) {
const trackTitle = normalizeForMatch(track.title || "");
const trackFilename = normalizeForMatch((track.filename || "").replace(/\.[^.]+$/, ""));
const matches =
(trackTitle && trackTitle === itemTitle) ||
(trackFilename && trackFilename === itemTitle) ||
(trackTitle && itemTitle && trackTitle.includes(itemTitle)) ||
(trackTitle && itemTitle && itemTitle.includes(trackTitle)) ||
(trackFilename && itemTitle && trackFilename.includes(itemTitle)) ||
(trackFilename && itemTitle && itemTitle.includes(trackFilename));
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);
}