The owner read the code and asked why `plugins/music/api/router.ts` was three
lines importing `@@/api/music/router` — platform code that knows the string
'music'. He was right, and tracing it found the justification was hollow.
The chain: server.tsx:20 imported the cliamp relay's two exports, which are
used only on commented-out lines; so the relay's functions were never invoked;
so its call to getMusicServerWsUrl never ran; and the file's other export,
getMusicServerUrl, had no consumers at all. A dead import held a music-named
file in the platform, and I documented that as a "seam" last night after
checking the import existed and stopping there.
Everything cliamp now lives in plugins/music/cliamp/:
sidecar/music/{cliamp-ws,pulse-audio}.ts, asoundrc, the test
api/cliamp/relay.ts
apps/FileBrowser/{CliampPanel,AudioStreamPlayer}.tsx
src/servers/sidecar/music/, src/servers/api/cliamp/ and src/servers/api/music/
are gone. server.tsx has no cliamp import, provider name, handler entry or
route. The platform contains no file named for music or cliamp.
Two of the things that moved were live, not inert.
The file browser's `Play` context-menu item, on any audio file or folder, set
?play= and rendered a cliamp terminal pointed at /api/cliamp/ws — a route that
upgraded into a handlers entry that was commented out, so handlers[provider]!
asserted non-null on undefined. Using that menu item crashed the socket
handler. Removed: the action, the layout, the panel wiring and both menu
entries. Verified the routes now 404 rather than crash.
That closed the totality drift as a side effect. server.tsx's route table and
its handlers map agree again for the first time since 2026-08-13, and
registry.test.ts now asserts it rather than pinning the hole.
The proxy is built in the plugin now, and its prefix is DERIVED. It was the
literal '/api/music', which the proxy uses to strip characters off the path —
correct only because mountPrefix returns /music for a first-party publisher.
The same plugin published by anyone else mounts at /api/p/<publisher>/music and
would have forwarded /alice/music/stream to a sidecar expecting /stream. A
latent bug only third parties would ever hit, and a quiet violation of the rule
that mountPrefix is the one function allowed to know about provenance. Offscale
has the identical hardcode and still needs it.
Still open there: appName is passed as a literal, because a plugin's router
cannot see its own directory name — the platform imports the module and reads
`router`, so there is nowhere to inject it. The fix is a factory the installer
calls with the plugin's identity.
Plugin backend coupling is down to 7 imports, all of them "a plugin talks to
its host": data-path, sidecar/connect, sidecar/protocol, officer-url, the
manifest type, officerdb/db and the users.id FK. Nothing music-shaped left.
bunx tsgo clean. 797 tests, 787 pass, same 7 pre-existing failures. Verified
live: manifest 200, favorites 200, stream 206, /api/cliamp/ws 404.
504 lines
25 KiB
TypeScript
504 lines
25 KiB
TypeScript
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
import { join, basename } from 'node:path';
|
|
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
|
|
import { createSidecarConnector } from '@@/sidecar/connect';
|
|
import { streamAudioFile } from './stream-audio';
|
|
import { cliampUpgradeData, musicWebsocket } from '../cliamp/cliamp-ws';
|
|
import { ensurePulseAudio } from '../cliamp/pulse-audio';
|
|
import { startNightlyReindex, stopNightlyReindex } from './nightly-reindex';
|
|
import {
|
|
reindexNow,
|
|
reindexFull,
|
|
ensureCacheSetup,
|
|
MUSIC_ROOT,
|
|
getIndexStatus,
|
|
getManifest,
|
|
albumVersion,
|
|
metaFilePath,
|
|
coverFilePath,
|
|
discographyFilePath,
|
|
posterFilePath,
|
|
lyricsFilePath,
|
|
onIndexProgress,
|
|
buildReport,
|
|
} from './indexer';
|
|
import {
|
|
getMusicFavorites,
|
|
addMusicFavorite,
|
|
removeMusicFavorite,
|
|
getNowPlaying,
|
|
setNowPlaying,
|
|
clearNowPlaying,
|
|
getPlaylists,
|
|
getPlaylist,
|
|
createPlaylist,
|
|
renamePlaylist,
|
|
deletePlaylist,
|
|
addPlaylistItems,
|
|
setPlaylistItems,
|
|
type FavoriteKind,
|
|
} from '../db/queries';
|
|
import { DATA_PATH } from '@@/data-path';
|
|
import { API_URL } from '@@/officer-url.mjs';
|
|
|
|
// ── Per-user state validation ──
|
|
// The authenticated user id arrives in X-Officer-User (the platform proxy injects it after auth; we're
|
|
// loopback-only so we trust it). Favorite/playlist `key`s are opaque paths we never interpret.
|
|
const userIdOf = (req: Request): number | null => {
|
|
const n = Number(req.headers.get('x-officer-user'));
|
|
return Number.isInteger(n) && n > 0 ? n : null;
|
|
};
|
|
const FAVORITE_KINDS = new Set<FavoriteKind>(['track', 'album', 'artist']);
|
|
const isKind = (k: unknown): k is FavoriteKind => typeof k === 'string' && FAVORITE_KINDS.has(k as FavoriteKind);
|
|
const PLAYLIST_NAME_MAX = 200;
|
|
const cleanName = (v: unknown): string | null => {
|
|
if (typeof v !== 'string') return null;
|
|
const n = v.trim();
|
|
return n && n.length <= PLAYLIST_NAME_MAX ? n : null;
|
|
};
|
|
const asKeys = (v: unknown): string[] | null =>
|
|
Array.isArray(v) && v.every((k) => typeof k === 'string' && k) ? (v as string[]) : null;
|
|
|
|
// 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 pure read of the last completed index (NO build triggered) —
|
|
// { version, generatedAt, albums: { "<rel>": { v, cover, tracks, videos?, 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 /lyrics?path=<rel>&file=<track> track lyrics text (X-Lyrics-Format: lrc|txt). ETag: <v>; 304. 404 if none.
|
|
// GET /image?path=<rel>&file=<img> loose folder image bytes (image/*, the ORIGINAL). 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[?full=1] run the build to COMPLETION, then return the final IndexStatus.
|
|
// default = incremental (skips unchanged); ?full=1 = full staged
|
|
// rebuild + atomic swap (backfill a meta-format change).
|
|
// 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".
|
|
//
|
|
// ── Per-user state (USER data in Postgres, not library data). User id in X-Officer-User, injected by
|
|
// the platform proxy after auth; `key`s are opaque paths (track homePath / album|artist rel). ──
|
|
// GET /favorites { tracks[], albums[], artists[] } (keys, newest first).
|
|
// POST /favorites { kind, key } add (idempotent). kind ∈ track|album|artist.
|
|
// DELETE /favorites?kind=&key= remove.
|
|
// GET /now-playing[?device=] last snapshot for that device, or null. device '' = default/phone, 'web' = browser.
|
|
// PUT /now-playing[?device=] { homePath, dir?, title?, artist?, album?, durationSec?, positionSec? } upsert.
|
|
// DELETE /now-playing[?device=] clear that device's snapshot.
|
|
// GET /playlists [{ id, name, count, createdAt, updatedAt }] (recent first).
|
|
// POST /playlists { name } create → 201 row; 409 if name taken.
|
|
// GET /playlists/:id { id, name, items:[keys], … }; 404 if not the user's.
|
|
// PATCH /playlists/:id { name } rename; 404 / 409.
|
|
// DELETE /playlists/:id delete (items cascade); 404.
|
|
// POST /playlists/:id/items { keys[] } append → { count }; 404.
|
|
// PUT /playlists/:id/items { keys[] } replace whole list (reorder/remove) → { count }; 404.
|
|
//
|
|
// `<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.
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
// ── 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();
|
|
|
|
// Ensure the cache is a symlink-to-slot before serving/building, so full reindexes can swap atomically.
|
|
await ensureCacheSetup();
|
|
|
|
// Nightly full reindex at 3am (staged + atomic swap).
|
|
startNightlyReindex();
|
|
|
|
// No filesystem watcher on ~/Music. Bun's recursive fs.watch costs one inotify watch per ENTRY, files
|
|
// included — ~92k for this library against a 65536 ceiling — so it could never establish, and the
|
|
// ENOSPC came back asynchronously as an unhandled 'error' event that killed this whole sidecar 17k
|
|
// times over. It also drained the per-UID watch pool, starving every other watcher on the machine.
|
|
// Reindexing is triggered instead: the ↻ button in the music browser (POST /reindex, incremental) and
|
|
// the nightly full rebuild above. `reindexFolder` in the indexer is retained and currently unused — it
|
|
// is the targeted hook for whatever writes to ~/Music (slskd, transmission, download-media) to declare
|
|
// the one folder it just wrote, which is the cheap version of what the watcher was guessing at.
|
|
|
|
// PulseAudio daemon + the `virtual_out` null sink both cliamp halves depend on. Officer used to do this at
|
|
// its own boot, which meant every restart of a process with no audio responsibilities re-checked the sink.
|
|
ensurePulseAudio();
|
|
|
|
const server = Bun.serve({
|
|
port,
|
|
hostname: '127.0.0.1',
|
|
// Bun caps the server-level idleTimeout at 255s. Keep it there as the baseline; the build/stream
|
|
// endpoints (which can idle for a whole from-scratch rebuild) extend it per-request via server.timeout.
|
|
idleTimeout: 255,
|
|
async fetch(req, server) {
|
|
const url = new URL(req.url);
|
|
|
|
// The two cliamp sockets. Officer has already authenticated the browser and is relaying frames; the
|
|
// player and the capture themselves live here (cliamp-ws.ts).
|
|
const wsData = cliampUpgradeData(url.pathname, url.searchParams);
|
|
if (wsData) {
|
|
if (server.upgrade(req, { data: wsData })) return undefined as unknown as Response;
|
|
return new Response('Expected a WebSocket upgrade', { status: 400 });
|
|
}
|
|
|
|
// A from-scratch reindex can take many minutes with no bytes flowing on the triggering request.
|
|
// Give the build endpoints a 30-min idle timeout so they aren't dropped (/manifest is a pure read now).
|
|
if (url.pathname === '/reindex' || url.pathname === '/reindex/stream') {
|
|
server.timeout(req, 1800);
|
|
}
|
|
const json = (data: unknown, init?: ResponseInit) =>
|
|
new Response(JSON.stringify(data), {
|
|
...init,
|
|
headers: { 'Content-Type': 'application/json', ...init?.headers },
|
|
});
|
|
// Version-stamped artifacts (cover/meta/…) and the manifest carry an ETag(=v) but must be REVALIDATED,
|
|
// not served blind from the browser cache — otherwise a changed cover keeps showing the old image at
|
|
// the same URL. `no-cache` = cache but always revalidate; the ETag/If-None-Match then makes it a cheap
|
|
// 304 when nothing changed. (The platform proxy forwards If-None-Match so this works end-to-end.)
|
|
const NO_CACHE = { 'Cache-Control': 'no-cache' } as const;
|
|
|
|
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 to completion, THEN respond — so the caller's manifest read right after is fresh. Both join an
|
|
// in-flight build rather than starting a second.
|
|
// default : incremental — near-instant, skips unchanged albums by version stamp.
|
|
// ?full=1 : full from-scratch rebuild into a fresh slot, swapped in atomically (staged + safe) —
|
|
// use to backfill a meta-format change (e.g. a new track field) across the WHOLE library.
|
|
const full = url.searchParams.get('full') === '1' || url.searchParams.get('full') === 'true';
|
|
const result = await (full ? reindexFull() : 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') {
|
|
// Pure read — returns the last completed index. It does NOT trigger a build (that could kick off a
|
|
// long/full rebuild on a plain app refresh); use POST /reindex explicitly to pick up disk changes.
|
|
return json(await getManifest(), { headers: NO_CACHE });
|
|
}
|
|
|
|
// 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, headers: NO_CACHE });
|
|
return new Response(Bun.file(posterPath), {
|
|
headers: { 'Content-Type': 'image/jpeg', ...NO_CACHE, ...(v ? { ETag: v } : {}) },
|
|
});
|
|
}
|
|
|
|
// Track lyrics (external .lrc/.txt sidecar or embedded, cached during indexing). Try synced first.
|
|
if (url.pathname === '/lyrics') {
|
|
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 });
|
|
for (const fmt of ['lrc', 'txt'] as const) {
|
|
const p = lyricsFilePath(rel, file, fmt);
|
|
if (p && (await Bun.file(p).exists())) {
|
|
const v = await albumVersion(rel);
|
|
if (v && req.headers.get('if-none-match') === v)
|
|
return new Response(null, { status: 304, headers: NO_CACHE });
|
|
return new Response(Bun.file(p), {
|
|
headers: {
|
|
'Content-Type': 'text/plain; charset=utf-8',
|
|
'X-Lyrics-Format': fmt,
|
|
...NO_CACHE,
|
|
...(v ? { ETag: v } : {}),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
return new Response('Not found', { status: 404 });
|
|
}
|
|
|
|
// Folder image (band photo / booklet scan) — served as the ORIGINAL file from the library folder
|
|
// (no cached artifact). `path` = music-relative folder, `file` = image name (basename'd for safety).
|
|
if (url.pathname === '/image') {
|
|
const rel = url.searchParams.get('path') ?? '';
|
|
const file = basename(url.searchParams.get('file') ?? '');
|
|
if (!file) return new Response('file is required', { status: 400 });
|
|
const abs = join(MUSIC_ROOT, rel, file);
|
|
if (abs !== MUSIC_ROOT && !abs.startsWith(MUSIC_ROOT + '/')) return new Response('Invalid path', { status: 400 });
|
|
if (!(await Bun.file(abs).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, headers: NO_CACHE });
|
|
const ext = file.slice(file.lastIndexOf('.') + 1).toLowerCase();
|
|
const type =
|
|
ext === 'png' ? 'image/png' : ext === 'webp' ? 'image/webp' : ext === 'gif' ? 'image/gif' : 'image/jpeg';
|
|
return new Response(Bun.file(abs), { headers: { 'Content-Type': type, ...NO_CACHE, ...(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, headers: NO_CACHE });
|
|
return new Response(Bun.file(spec.file), {
|
|
headers: { 'Content-Type': spec.type, ...NO_CACHE, ...(v ? { ETag: v } : {}) },
|
|
});
|
|
}
|
|
|
|
// ── Per-user state (favorites / now-playing / playlists) ──────────────────────────────────────
|
|
// USER data backed by Postgres (NOT library data). The user id comes from X-Officer-User (see above).
|
|
const P = url.pathname;
|
|
if (P === '/favorites' || P === '/now-playing' || P === '/playlists' || P.startsWith('/playlists/')) {
|
|
const uid = userIdOf(req);
|
|
if (uid === null) return json({ error: 'unauthenticated' }, { status: 401 });
|
|
const m = req.method;
|
|
|
|
if (P === '/favorites') {
|
|
if (m === 'GET') return json(await getMusicFavorites(uid));
|
|
if (m === 'POST') {
|
|
const { kind, key } = (await req.json().catch(() => ({}))) as { kind?: unknown; key?: unknown };
|
|
if (!isKind(kind) || typeof key !== 'string' || !key)
|
|
return json({ error: 'kind and key required' }, { status: 400 });
|
|
await addMusicFavorite(uid, kind, key);
|
|
return json({ ok: true });
|
|
}
|
|
if (m === 'DELETE') {
|
|
const kind = url.searchParams.get('kind');
|
|
const key = url.searchParams.get('key');
|
|
if (!isKind(kind) || !key) return json({ error: 'kind and key required' }, { status: 400 });
|
|
await removeMusicFavorite(uid, kind, key);
|
|
return json({ ok: true });
|
|
}
|
|
return new Response('Method not allowed', { status: 405 });
|
|
}
|
|
|
|
if (P === '/now-playing') {
|
|
// Per-client resume state: `?device=` tags the caller ('' = default/phone, 'web' = the browser)
|
|
// so each keeps its own now-playing instead of sharing one row. Missing → '' (back-compat).
|
|
const device = url.searchParams.get('device') ?? '';
|
|
if (m === 'GET') return json(await getNowPlaying(uid, device));
|
|
if (m === 'PUT') {
|
|
const b = (await req.json().catch(() => ({}))) as Record<string, unknown>;
|
|
if (typeof b.homePath !== 'string' || !b.homePath)
|
|
return json({ error: 'homePath required' }, { status: 400 });
|
|
const str = (v: unknown) => (typeof v === 'string' ? v : undefined);
|
|
const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
|
|
await setNowPlaying(uid, device, {
|
|
homePath: b.homePath,
|
|
dir: str(b.dir),
|
|
title: str(b.title),
|
|
artist: str(b.artist),
|
|
album: str(b.album),
|
|
durationSec: num(b.durationSec),
|
|
positionSec: num(b.positionSec),
|
|
});
|
|
return json({ ok: true });
|
|
}
|
|
if (m === 'DELETE') {
|
|
await clearNowPlaying(uid, device);
|
|
return json({ ok: true });
|
|
}
|
|
return new Response('Method not allowed', { status: 405 });
|
|
}
|
|
|
|
if (P === '/playlists') {
|
|
if (m === 'GET') return json(await getPlaylists(uid));
|
|
if (m === 'POST') {
|
|
const { name } = (await req.json().catch(() => ({}))) as { name?: unknown };
|
|
const n = cleanName(name);
|
|
if (!n) return json({ error: 'name required (1-200 chars)' }, { status: 400 });
|
|
const row = await createPlaylist(uid, n);
|
|
return row
|
|
? json(row, { status: 201 })
|
|
: json({ error: 'a playlist with that name already exists' }, { status: 409 });
|
|
}
|
|
return new Response('Method not allowed', { status: 405 });
|
|
}
|
|
|
|
// /playlists/:id and /playlists/:id/items
|
|
const match = P.match(/^\/playlists\/(\d+)(\/items)?$/);
|
|
if (!match) return json({ error: 'not found' }, { status: 404 });
|
|
const id = Number(match[1]);
|
|
|
|
if (match[2]) {
|
|
// /playlists/:id/items — POST append, PUT replace (reorder/remove)
|
|
if (m !== 'POST' && m !== 'PUT') return new Response('Method not allowed', { status: 405 });
|
|
const { keys } = (await req.json().catch(() => ({}))) as { keys?: unknown };
|
|
const ks = asKeys(keys);
|
|
if (!ks) return json({ error: 'keys[] required' }, { status: 400 });
|
|
const count = await (m === 'POST' ? addPlaylistItems(uid, id, ks) : setPlaylistItems(uid, id, ks));
|
|
return count === null ? json({ error: 'not found' }, { status: 404 }) : json({ count });
|
|
}
|
|
|
|
if (m === 'GET') {
|
|
const pl = await getPlaylist(uid, id);
|
|
return pl ? json(pl) : json({ error: 'not found' }, { status: 404 });
|
|
}
|
|
if (m === 'PATCH') {
|
|
const { name } = (await req.json().catch(() => ({}))) as { name?: unknown };
|
|
const n = cleanName(name);
|
|
if (!n) return json({ error: 'name required (1-200 chars)' }, { status: 400 });
|
|
if (!(await getPlaylist(uid, id))) return json({ error: 'not found' }, { status: 404 });
|
|
const ok = await renamePlaylist(uid, id, n);
|
|
return ok ? json({ ok: true }) : json({ error: 'a playlist with that name already exists' }, { status: 409 });
|
|
}
|
|
if (m === 'DELETE') {
|
|
const ok = await deletePlaylist(uid, id);
|
|
return ok ? json({ ok: true }) : json({ error: 'not found' }, { status: 404 });
|
|
}
|
|
return new Response('Method not allowed', { status: 405 });
|
|
}
|
|
|
|
return new Response('Not found', { status: 404 });
|
|
},
|
|
websocket: musicWebsocket,
|
|
});
|
|
|
|
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...`);
|
|
stopNightlyReindex();
|
|
try {
|
|
server.stop(true);
|
|
} catch {
|
|
/* already stopped */
|
|
}
|
|
connection.destroy();
|
|
process.exit(0);
|
|
}
|
|
|
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
process.on('SIGINT', () => shutdown('SIGINT'));
|