blastoise/init.ts

407 lines
15 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
// Skip if both title and filename are too short
if ((!trackTitle || trackTitle.length < 5) && (!trackFilename || trackFilename.length < 5)) return;
console.log(`[ytdlp] Checking track against ${pendingPlaylistTracks.size} pending: title="${trackTitle}" filename="${trackFilename}"`);
for (const [pendingTitle, pending] of pendingPlaylistTracks) {
const normalizedPending = normalizeForMatch(pendingTitle);
// Skip if pending title is too short
if (!normalizedPending || normalizedPending.length < 5) continue;
// Match by title or filename (yt-dlp uses title as filename)
// Require exact match or very high overlap
const matches =
(trackTitle && trackTitle === normalizedPending) ||
(trackFilename && trackFilename === normalizedPending) ||
(trackTitle && normalizedPending && trackTitle.includes(normalizedPending) && normalizedPending.length >= trackTitle.length * 0.8) ||
(trackTitle && normalizedPending && normalizedPending.includes(trackTitle) && trackTitle.length >= normalizedPending.length * 0.8) ||
(trackFilename && normalizedPending && trackFilename.includes(normalizedPending) && normalizedPending.length >= trackFilename.length * 0.8) ||
(trackFilename && normalizedPending && normalizedPending.includes(trackFilename) && trackFilename.length >= normalizedPending.length * 0.8);
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);
// Skip if title is too short (avoid false matches)
if (!itemTitle || itemTitle.length < 5) continue;
// Check if any library track matches
for (const track of tracks) {
const trackTitle = normalizeForMatch(track.title || "");
const trackFilename = normalizeForMatch((track.filename || "").replace(/\.[^.]+$/, ""));
// Skip if both track title and filename are too short
if ((!trackTitle || trackTitle.length < 5) && (!trackFilename || trackFilename.length < 5)) continue;
// Require exact match or very high overlap (not just substring)
const matches =
(trackTitle && trackTitle === itemTitle) ||
(trackFilename && trackFilename === itemTitle) ||
// Only allow includes if the shorter string is at least 80% of the longer
(trackTitle && itemTitle && trackTitle.includes(itemTitle) && itemTitle.length >= trackTitle.length * 0.8) ||
(trackTitle && itemTitle && itemTitle.includes(trackTitle) && trackTitle.length >= itemTitle.length * 0.8) ||
(trackFilename && itemTitle && trackFilename.includes(itemTitle) && itemTitle.length >= trackFilename.length * 0.8) ||
(trackFilename && itemTitle && itemTitle.includes(trackFilename) && trackFilename.length >= itemTitle.length * 0.8);
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);
}