Each indexed video gets a compressed poster (a frame grab ~10% in, capped at
30s, scaled ≤600px q5 like covers), written to cache/<rel>/posters/<file>.jpg
and recorded as `poster` on the meta.videos entry. The posters dir is wiped and
regenerated on each rebuild so orphans (removed videos) don't linger. New
`postersSaved` status counter.
Served by a new sidecar route GET /api/music/poster?path=<rel>&file=<video>
(image/jpeg, ETag=<v>, 304, 404 when none) — path-safe via basename.
Verified end-to-end on a real .mp4: video-only album → manifest {tracks:0,
videos:1}, meta.poster set, 14 KB poster on disk. MUSIC_API.md documents the
poster field + endpoint + postersSaved.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
251 lines
11 KiB
TypeScript
251 lines
11 KiB
TypeScript
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
|
import { createSidecarConnector } from '../connect';
|
|
import { streamAudioFile } from './stream-audio';
|
|
import {
|
|
reindexNow,
|
|
ensureIndexFresh,
|
|
getIndexStatus,
|
|
getManifest,
|
|
albumVersion,
|
|
metaFilePath,
|
|
coverFilePath,
|
|
discographyFilePath,
|
|
posterFilePath,
|
|
onIndexProgress,
|
|
buildReport,
|
|
} from './indexer';
|
|
|
|
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
|
|
|
// The music sidecar (officer-music). Same philosophy as the other officer-* sidecars: a singleton
|
|
// process that registers with the API server. It OWNS an audio-streaming HTTP server (all path
|
|
// resolution + streaming + ffprobe duration happens here); the platform API is just a thin proxy that
|
|
// authenticates and forwards to us. The server listens on a random loopback port, reported to the API
|
|
// on connect so it can route `/api/music/*` here.
|
|
//
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
// HTTP CONTRACT — the full `/api/music/*` surface (this fetch handler is the source of truth; the
|
|
// platform side is an opaque catch-all proxy). All routes are reached as `/api/music/<name>`, authed
|
|
// upstream by userMiddleware (Bearer header or `?token=` for media). Data shapes are the exported
|
|
// `IndexStatus` / `IndexReport` / `IndexMeta` types in indexer.ts.
|
|
//
|
|
// GET /stream?path=<home-relative> audio with Range→206 (Content-Range/Length/Accept-Ranges)
|
|
// + `X-Audio-Duration` (seconds, ffprobe). 400/404/416.
|
|
// GET /manifest ensures a fresh index (debounced rebuild) then returns
|
|
// { version, generatedAt, albums: { "<rel>": { v, cover, tracks, disco? } } }
|
|
// GET /meta?path=<rel> album meta.json (IndexMeta). ETag: <v>; If-None-Match → 304.
|
|
// GET /cover?path=<rel> compressed cover.jpg. ETag: <v>; If-None-Match → 304.
|
|
// GET /poster?path=<rel>&file=<video> compressed video poster (frame grab). ETag: <v>; 304. 404 if none.
|
|
// GET /discography?path=<artist rel> artist discography.json (only where manifest entry has
|
|
// disco:true) = { artist, albums: { "<[year] album folder>":
|
|
// "<Type>" } }, Type ∈ Studio/Live/Compilation/Single/EP/…
|
|
// ETag: <v>; If-None-Match → 304. Source: each artist folder's
|
|
// _discography.md (normalized; the md itself is never modified).
|
|
// POST /reindex run the build to COMPLETION, then return the final IndexStatus.
|
|
// GET /reindex/status IndexStatus snapshot.
|
|
// GET /reindex/stream SSE. Triggers a build if idle (`?trigger=0` = watch-only).
|
|
// `event: progress` (IndexStatus) throttled ~200ms, then one
|
|
// `event: done` (IndexReport) and the stream closes.
|
|
// GET /health "ok".
|
|
//
|
|
// `<rel>` = album folder path relative to the Music root (e.g. "Albums/AC-DC/[1980] Back in Black").
|
|
// `v` = per-album version stamp; unchanged `v` ⇒ nothing changed ⇒ the phone can skip re-downloading.
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
|
|
|
// ── Audio-streaming HTTP server ──
|
|
|
|
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
|
function getFreePort(): number {
|
|
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
|
const port = probe.port;
|
|
probe.stop(true);
|
|
if (port == null) throw new Error('failed to acquire a free port');
|
|
return port;
|
|
}
|
|
|
|
const port = getFreePort();
|
|
|
|
const server = Bun.serve({
|
|
port,
|
|
hostname: '127.0.0.1',
|
|
async fetch(req) {
|
|
const url = new URL(req.url);
|
|
const json = (data: unknown, init?: ResponseInit) =>
|
|
new Response(JSON.stringify(data), { ...init, headers: { 'Content-Type': 'application/json', ...init?.headers } });
|
|
|
|
if (url.pathname === '/health') return new Response('ok');
|
|
|
|
// ── Streaming ──
|
|
if (url.pathname === '/stream') {
|
|
const path = url.searchParams.get('path');
|
|
if (!path) return new Response('path is required', { status: 400 });
|
|
return streamAudioFile(path, req.headers.get('range'));
|
|
}
|
|
|
|
// ── Index build ──
|
|
if (url.pathname === '/reindex') {
|
|
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
// Run the resync to completion, THEN respond — so the caller's manifest read right after is fresh.
|
|
// Incremental builds are near-instant (unchanged albums skip by version stamp). reindexNow joins
|
|
// an in-flight build rather than starting a second.
|
|
const result = await reindexNow();
|
|
return json(result);
|
|
}
|
|
if (url.pathname === '/reindex/status') return json(getIndexStatus());
|
|
|
|
// SSE progress stream (for the app + the CLI). Triggers a build if idle (unless ?trigger=0), then
|
|
// streams `progress` events until the build finishes, ending with a `done` event carrying the report.
|
|
if (url.pathname === '/reindex/stream') {
|
|
const trigger = url.searchParams.get('trigger') !== '0';
|
|
if (trigger) void reindexNow();
|
|
|
|
const encoder = new TextEncoder();
|
|
const stream = new ReadableStream({
|
|
start(controller) {
|
|
let closed = false;
|
|
let unsub = () => {};
|
|
const send = (event: string, data: unknown) => {
|
|
if (closed) return;
|
|
try {
|
|
controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
|
|
} catch {
|
|
/* stream closed */
|
|
}
|
|
};
|
|
const finish = (s: ReturnType<typeof getIndexStatus>) => {
|
|
send('done', buildReport(s));
|
|
unsub();
|
|
closed = true;
|
|
try {
|
|
controller.close();
|
|
} catch {
|
|
/* already closed */
|
|
}
|
|
};
|
|
|
|
send('progress', getIndexStatus());
|
|
const cur = getIndexStatus();
|
|
if (!cur.running) {
|
|
finish(cur); // nothing running → emit the last report and close
|
|
return;
|
|
}
|
|
unsub = onIndexProgress((s) => {
|
|
send('progress', s);
|
|
if (!s.running && s.finishedAt) finish(s);
|
|
});
|
|
},
|
|
});
|
|
|
|
return new Response(stream, {
|
|
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' },
|
|
});
|
|
}
|
|
|
|
// ── Sync surface ──
|
|
if (url.pathname === '/manifest') {
|
|
// Every app refresh funnels through here, so rebuild the index first (debounced) — this is what
|
|
// makes on-disk changes show up on a plain refresh, not only via the explicit reindex sheet.
|
|
await ensureIndexFresh();
|
|
return json(await getManifest());
|
|
}
|
|
|
|
// Video poster (a compressed frame grab). Keyed by the video's folder rel + its filename.
|
|
if (url.pathname === '/poster') {
|
|
const rel = url.searchParams.get('path');
|
|
const file = url.searchParams.get('file');
|
|
if (rel === null || !file) return new Response('path and file are required', { status: 400 });
|
|
const posterPath = posterFilePath(rel, file);
|
|
if (!posterPath) return new Response('Invalid path', { status: 400 });
|
|
if (!(await Bun.file(posterPath).exists())) return new Response('Not found', { status: 404 });
|
|
const v = await albumVersion(rel);
|
|
if (v && req.headers.get('if-none-match') === v) return new Response(null, { status: 304 });
|
|
return new Response(Bun.file(posterPath), { headers: { 'Content-Type': 'image/jpeg', ...(v ? { ETag: v } : {}) } });
|
|
}
|
|
|
|
if (url.pathname === '/meta' || url.pathname === '/cover' || url.pathname === '/discography') {
|
|
const rel = url.searchParams.get('path');
|
|
if (rel === null) return new Response('path is required', { status: 400 });
|
|
const spec = {
|
|
'/meta': { file: metaFilePath(rel), type: 'application/json' },
|
|
'/cover': { file: coverFilePath(rel), type: 'image/jpeg' },
|
|
'/discography': { file: discographyFilePath(rel), type: 'application/json' },
|
|
}[url.pathname]!;
|
|
if (!spec.file) return new Response('Invalid path', { status: 400 });
|
|
if (!(await Bun.file(spec.file).exists())) return new Response('Not found', { status: 404 });
|
|
|
|
const v = await albumVersion(rel);
|
|
if (v && req.headers.get('if-none-match') === v) return new Response(null, { status: 304 });
|
|
return new Response(Bun.file(spec.file), {
|
|
headers: { 'Content-Type': spec.type, ...(v ? { ETag: v } : {}) },
|
|
});
|
|
}
|
|
|
|
return new Response('Not found', { status: 404 });
|
|
},
|
|
});
|
|
|
|
console.log(`[music] audio server listening on http://127.0.0.1:${port}`);
|
|
|
|
// Write the port to a well-known file so local tooling (scripts/reindex-music.ts) can find the server.
|
|
try {
|
|
mkdirSync(join(DATA_PATH, 'music'), { recursive: true });
|
|
writeFileSync(join(DATA_PATH, 'music', '.server'), String(port));
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
|
|
// ── Command handlers ──
|
|
|
|
type ReplyFn = (msg: SidecarEvent) => void;
|
|
|
|
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
|
switch (cmd.type) {
|
|
case 'ping':
|
|
reply({ type: 'pong', id: cmd.id });
|
|
break;
|
|
|
|
default:
|
|
reply({
|
|
type: 'error',
|
|
id: (cmd as SidecarCommand).id,
|
|
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── Connect to API server ──
|
|
|
|
const connection = createSidecarConnector({
|
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
|
name: 'music',
|
|
capabilities: ['music'],
|
|
onCommand(cmd, reply) {
|
|
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
|
},
|
|
onConnected() {
|
|
// Tell the API where our audio server is listening, so it can proxy /api/music/* here.
|
|
connection.send({ type: 'music:server', port });
|
|
console.log(`[music] reported audio server port ${port} to API`);
|
|
},
|
|
});
|
|
|
|
// ── Graceful shutdown ──
|
|
|
|
function shutdown(signal: string) {
|
|
console.log(`[music] ${signal} received, shutting down...`);
|
|
try {
|
|
server.stop(true);
|
|
} catch {
|
|
/* already stopped */
|
|
}
|
|
connection.destroy();
|
|
process.exit(0);
|
|
}
|
|
|
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
process.on('SIGINT', () => shutdown('SIGINT'));
|