181 lines
5.7 KiB
TypeScript
181 lines
5.7 KiB
TypeScript
import { file, serve, type ServerWebSocket } from "bun";
|
|
import { parseFile } from "music-metadata";
|
|
import { Stream, type Track } from "./stream";
|
|
import { readdir, stat } from "fs/promises";
|
|
import { join } from "path";
|
|
|
|
const MUSIC_DIR = join(import.meta.dir, "music");
|
|
const PLAYLIST_PATH = join(import.meta.dir, "playlist.json");
|
|
const PUBLIC_DIR = join(import.meta.dir, "public");
|
|
|
|
// Load track metadata
|
|
async function loadTrack(filename: string): Promise<Track> {
|
|
const filepath = join(MUSIC_DIR, filename);
|
|
try {
|
|
const metadata = await parseFile(filepath, { duration: true });
|
|
const duration = metadata.format.duration ?? 0;
|
|
const title = metadata.common.title ?? filename.replace(/\.[^.]+$/, "");
|
|
console.log(`Track: ${filename} | duration: ${duration}s | title: ${title}`);
|
|
return { filename, title, duration };
|
|
} catch (e) {
|
|
console.warn(`Could not read metadata for ${filename}, skipping`);
|
|
return { filename, title: filename.replace(/\.[^.]+$/, ""), duration: 0 };
|
|
}
|
|
}
|
|
|
|
// Auto-discover tracks if playlist is empty
|
|
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 [];
|
|
}
|
|
}
|
|
|
|
// Initialize streams
|
|
async function init(): Promise<Map<string, Stream>> {
|
|
const playlistData = await file(PLAYLIST_PATH).json();
|
|
const streams = new Map<string, Stream>();
|
|
|
|
for (const cfg of playlistData.streams) {
|
|
let trackFiles: string[] = cfg.tracks;
|
|
if (trackFiles.length === 0) {
|
|
trackFiles = await discoverTracks();
|
|
console.log(`Stream "${cfg.id}": auto-discovered ${trackFiles.length} tracks`);
|
|
}
|
|
const tracks = await Promise.all(trackFiles.map(loadTrack));
|
|
const validTracks = tracks.filter((t) => t.duration > 0);
|
|
if (validTracks.length === 0) {
|
|
console.warn(`Stream "${cfg.id}" has no valid tracks, skipping`);
|
|
continue;
|
|
}
|
|
const stream = new Stream({ id: cfg.id, name: cfg.name, tracks: validTracks });
|
|
streams.set(cfg.id, stream);
|
|
console.log(`Stream "${cfg.id}": ${validTracks.length} tracks loaded`);
|
|
}
|
|
|
|
return streams;
|
|
}
|
|
|
|
const streams = await init();
|
|
|
|
// Tick interval: advance tracks when needed, broadcast every 30s
|
|
let tickCount = 0;
|
|
setInterval(() => {
|
|
tickCount++;
|
|
for (const stream of streams.values()) {
|
|
const changed = stream.tick();
|
|
if (!changed && tickCount % 30 === 0) {
|
|
stream.broadcast();
|
|
}
|
|
}
|
|
}, 1000);
|
|
|
|
type WsData = { streamId: string };
|
|
|
|
serve({
|
|
port: parseInt("3001"),
|
|
async fetch(req, server) {
|
|
const url = new URL(req.url);
|
|
const path = url.pathname;
|
|
|
|
// WebSocket upgrade
|
|
if (path.match(/^\/api\/streams\/([^/]+)\/ws$/)) {
|
|
const id = path.split("/")[3];
|
|
if (!streams.has(id)) return new Response("Stream not found", { status: 404 });
|
|
const ok = server.upgrade(req, { data: { streamId: id } });
|
|
if (ok) return undefined;
|
|
return new Response("WebSocket upgrade failed", { status: 500 });
|
|
}
|
|
|
|
// API: list streams
|
|
if (path === "/api/streams") {
|
|
const list = [...streams.values()].map((s) => ({
|
|
id: s.id,
|
|
name: s.name,
|
|
trackCount: s.playlist.length,
|
|
}));
|
|
return Response.json(list);
|
|
}
|
|
|
|
// API: stream state
|
|
const streamMatch = path.match(/^\/api\/streams\/([^/]+)$/);
|
|
if (streamMatch) {
|
|
const stream = streams.get(streamMatch[1]);
|
|
if (!stream) return new Response("Not found", { status: 404 });
|
|
return Response.json(stream.getState());
|
|
}
|
|
|
|
// API: serve audio file
|
|
const trackMatch = path.match(/^\/api\/tracks\/(.+)$/);
|
|
if (trackMatch) {
|
|
const filename = decodeURIComponent(trackMatch[1]);
|
|
if (filename.includes("..")) return new Response("Forbidden", { status: 403 });
|
|
const filepath = join(MUSIC_DIR, filename);
|
|
const f = file(filepath);
|
|
if (!(await f.exists())) return new Response("Not found", { status: 404 });
|
|
|
|
const size = f.size;
|
|
const range = req.headers.get("range");
|
|
|
|
if (range) {
|
|
const match = range.match(/bytes=(\d+)-(\d*)/);
|
|
if (match) {
|
|
const start = parseInt(match[1]);
|
|
const end = match[2] ? parseInt(match[2]) : size - 1;
|
|
const chunk = f.slice(start, end + 1);
|
|
return new Response(chunk, {
|
|
status: 206,
|
|
headers: {
|
|
"Content-Range": `bytes ${start}-${end}/${size}`,
|
|
"Accept-Ranges": "bytes",
|
|
"Content-Length": String(end - start + 1),
|
|
"Content-Type": f.type || "audio/mpeg",
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
return new Response(f, {
|
|
headers: {
|
|
"Accept-Ranges": "bytes",
|
|
"Content-Length": String(size),
|
|
"Content-Type": f.type || "audio/mpeg",
|
|
},
|
|
});
|
|
}
|
|
|
|
// Serve static client
|
|
if (path === "/" || path === "/index.html") {
|
|
return new Response(file(join(PUBLIC_DIR, "index.html")), {
|
|
headers: { "Content-Type": "text/html" },
|
|
});
|
|
}
|
|
|
|
return new Response("Not found", { status: 404 });
|
|
},
|
|
|
|
websocket: {
|
|
open(ws: ServerWebSocket<WsData>) {
|
|
const stream = streams.get(ws.data.streamId);
|
|
if (stream) stream.addClient(ws);
|
|
},
|
|
close(ws: ServerWebSocket<WsData>) {
|
|
const stream = streams.get(ws.data.streamId);
|
|
if (stream) stream.removeClient(ws);
|
|
},
|
|
message(ws: ServerWebSocket<WsData>, message: string | Buffer) {
|
|
const stream = streams.get(ws.data.streamId);
|
|
if (!stream) return;
|
|
try {
|
|
const data = JSON.parse(String(message));
|
|
if (data.action === "pause") stream.pause();
|
|
else if (data.action === "unpause") stream.unpause();
|
|
} catch {}
|
|
},
|
|
},
|
|
});
|
|
|
|
console.log("MusicRoom running on http://localhost:3001");
|