music, extracted from the platform into its own repository
Everything the plugin is, moved out of officerdev/platform on 2026-08-15 — 41 files, unchanged from the tree they left. manifest.ts identity, one permission, ffmpeg/ffprobe declared api/ the sidecar proxy; the prefix comes from mountPrefix() sidecar/ the whole /api/music contract — indexing, streaming, per-user state db/ music_favorites, _playlists, _playlist_items, _now_playing web/ panels, layout, and the player: engine, bar, lyrics, favourites cliamp/ the second playback path, parked — not working, kept deliberately widgets/ the dashboard widget, parked — plugins cannot contribute widgets assets/ icon.png, the dock tile scripts/ the reindex CLI PLUGIN.md is the design record: what moved, what stayed, what broke, and why. MUSIC_API.md is the contract the phone and tablet apps speak, and the reason the sidecar's HTTP shape is not free to change. ── It does not build here, and that is the point ── The platform resolves `hooks/useClient`, `officerdev`, `officerdb/db` and `@@/*` through the workspace links in its own node_modules. Measured from this directory, outside the platform checkout, every one of them fails to resolve — 7 imports in the backend, ~29 in the frontend. So this repository is the source of truth, not yet a buildable unit. Making it one means the host API becoming something a plugin can depend on rather than something it reaches into. That is the next problem, and having the code here is what makes it unavoidable rather than theoretical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { resolve, sep } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
// All processing lives here (the platform is just a proxy). Files live under the owner's home — single
|
||||
// super user, so HOME_DIR. Paths from the app are home-relative (e.g. "Music/Artist/Album/track.mp3"),
|
||||
// exactly like file-browser /raw.
|
||||
const ROOT_DIR = homedir();
|
||||
|
||||
const CONTENT_TYPES: Record<string, string> = {
|
||||
mp3: 'audio/mpeg',
|
||||
m4a: 'audio/mp4',
|
||||
mp4: 'audio/mp4',
|
||||
aac: 'audio/aac',
|
||||
flac: 'audio/flac',
|
||||
wav: 'audio/wav',
|
||||
ogg: 'audio/ogg',
|
||||
opus: 'audio/opus',
|
||||
wma: 'audio/x-ms-wma',
|
||||
};
|
||||
|
||||
// Probe duration once per file (keyed by absolute path + mtime) — the player makes many range requests
|
||||
// per track, and we don't want to shell out to ffprobe on each one.
|
||||
const durationCache = new Map<string, number>();
|
||||
|
||||
async function probeDuration(absPath: string, mtimeMs: number): Promise<number | undefined> {
|
||||
const key = `${absPath}:${mtimeMs}`;
|
||||
const cached = durationCache.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
try {
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
'ffprobe',
|
||||
'-v',
|
||||
'error',
|
||||
'-show_entries',
|
||||
'format=duration',
|
||||
'-of',
|
||||
'default=noprint_wrappers=1:nokey=1',
|
||||
absPath,
|
||||
],
|
||||
{ stdout: 'pipe', stderr: 'ignore' },
|
||||
);
|
||||
const out = (await new Response(proc.stdout).text()).trim();
|
||||
await proc.exited;
|
||||
const d = parseFloat(out);
|
||||
if (Number.isFinite(d) && d > 0) {
|
||||
durationCache.set(key, d);
|
||||
return d;
|
||||
}
|
||||
} catch {
|
||||
/* ffprobe missing or failed — no duration header */
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Resolve a home-relative path within ROOT_DIR; null if it escapes (traversal). */
|
||||
function resolveWithinRoot(relPath: string): string | null {
|
||||
const clean = relPath.replace(/^\/+/, '');
|
||||
const abs = resolve(ROOT_DIR, clean);
|
||||
if (abs !== ROOT_DIR && !abs.startsWith(ROOT_DIR + sep)) return null;
|
||||
return abs;
|
||||
}
|
||||
|
||||
/** Serve an audio file with byte-range support + an X-Audio-Duration header (ffprobe-derived). */
|
||||
export async function streamAudioFile(relPath: string, rangeHeader: string | null): Promise<Response> {
|
||||
const absPath = resolveWithinRoot(relPath);
|
||||
if (!absPath) return new Response('Invalid path', { status: 400 });
|
||||
|
||||
let s;
|
||||
try {
|
||||
s = await stat(absPath);
|
||||
} catch {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
if (!s.isFile()) return new Response('Not a file', { status: 404 });
|
||||
|
||||
const total = s.size;
|
||||
const ext = absPath.slice(absPath.lastIndexOf('.') + 1).toLowerCase();
|
||||
const contentType = CONTENT_TYPES[ext] ?? 'application/octet-stream';
|
||||
const duration = await probeDuration(absPath, s.mtimeMs);
|
||||
const file = Bun.file(absPath);
|
||||
|
||||
const baseHeaders: Record<string, string> = {
|
||||
'Content-Type': contentType,
|
||||
'Accept-Ranges': 'bytes',
|
||||
...(duration ? { 'X-Audio-Duration': String(duration) } : {}),
|
||||
};
|
||||
|
||||
if (rangeHeader) {
|
||||
const m = rangeHeader.match(/bytes=(\d*)-(\d*)/);
|
||||
if (m) {
|
||||
const start = m[1] ? parseInt(m[1], 10) : 0;
|
||||
const end = m[2] ? parseInt(m[2], 10) : total - 1;
|
||||
if (Number.isNaN(start) || start < 0 || end >= total || start > end) {
|
||||
return new Response('Invalid range', { status: 416, headers: { 'Content-Range': `bytes */${total}` } });
|
||||
}
|
||||
return new Response(file.slice(start, end + 1), {
|
||||
status: 206,
|
||||
headers: {
|
||||
...baseHeaders,
|
||||
'Content-Range': `bytes ${start}-${end}/${total}`,
|
||||
'Content-Length': String(end - start + 1),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(file, { status: 200, headers: { ...baseHeaders, 'Content-Length': String(total) } });
|
||||
}
|
||||
Reference in New Issue
Block a user