music: server-side library indexer + sync surface
The officer-music sidecar now builds a cache tree mirroring the library (server
counterpart of the app's music-index.ts), and exposes an rsync-clean diff surface.
Indexer (indexer.ts): walks HOME_DIR/Music; per album computes a version `v` =
hash of the source signature (track name+size+mtime, cover size+mtime); ffprobe
→ meta.json (phone IndexMeta schema: file/title/artist/albumArtist/album/track/
year/durationSec); ffmpeg compresses the cover to <=600px q5 cover.jpg. Writes
DATA_PATH/music/cache/<rel>/. Incremental (skip albums whose `v` is unchanged),
prunes cache dirs for albums removed from the library, maintains manifest.json.
Endpoints (sidecar, auto-proxied by /api/music/*):
POST /reindex async build; GET /reindex/status polls progress
GET /manifest { version, albums: { "<rel>": { v, cover, tracks } } }
GET /meta?path=<rel> album meta.json (ETag: v, 304 on If-None-Match)
GET /cover?path=<rel> compressed cover (ETag: v, 304 on If-None-Match)
Phone resync: GET /manifest, diff `v` against last-stored → fetch only changed
albums' meta+cover; drop rels missing from the manifest. No re-download of
unchanged albums.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,14 @@
|
|||||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||||
import { createSidecarConnector } from '../connect';
|
import { createSidecarConnector } from '../connect';
|
||||||
import { streamAudioFile } from './stream-audio';
|
import { streamAudioFile } from './stream-audio';
|
||||||
|
import {
|
||||||
|
buildMusicIndex,
|
||||||
|
getIndexStatus,
|
||||||
|
getManifest,
|
||||||
|
albumVersion,
|
||||||
|
metaFilePath,
|
||||||
|
coverFilePath,
|
||||||
|
} from './indexer';
|
||||||
|
|
||||||
// The music sidecar (officer-music). Same philosophy as the other officer-* sidecars: a singleton
|
// 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
|
// process that registers with the API server. It OWNS an audio-streaming HTTP server (all path
|
||||||
@@ -28,12 +36,47 @@ const server = Bun.serve({
|
|||||||
hostname: '127.0.0.1',
|
hostname: '127.0.0.1',
|
||||||
async fetch(req) {
|
async fetch(req) {
|
||||||
const url = new URL(req.url);
|
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');
|
if (url.pathname === '/health') return new Response('ok');
|
||||||
|
|
||||||
|
// ── Streaming ──
|
||||||
if (url.pathname === '/stream') {
|
if (url.pathname === '/stream') {
|
||||||
const path = url.searchParams.get('path');
|
const path = url.searchParams.get('path');
|
||||||
if (!path) return new Response('path is required', { status: 400 });
|
if (!path) return new Response('path is required', { status: 400 });
|
||||||
return streamAudioFile(path, req.headers.get('range'));
|
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 });
|
||||||
|
void buildMusicIndex(); // fire-and-forget; sets running=true synchronously before the first await
|
||||||
|
return json(getIndexStatus());
|
||||||
|
}
|
||||||
|
if (url.pathname === '/reindex/status') return json(getIndexStatus());
|
||||||
|
|
||||||
|
// ── Sync surface ──
|
||||||
|
if (url.pathname === '/manifest') return json(await getManifest());
|
||||||
|
|
||||||
|
if (url.pathname === '/meta' || url.pathname === '/cover') {
|
||||||
|
const rel = url.searchParams.get('path');
|
||||||
|
if (rel === null) return new Response('path is required', { status: 400 });
|
||||||
|
const isMeta = url.pathname === '/meta';
|
||||||
|
const file = isMeta ? metaFilePath(rel) : coverFilePath(rel);
|
||||||
|
if (!file) return new Response('Invalid path', { status: 400 });
|
||||||
|
if (!(await Bun.file(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(file), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': isMeta ? 'application/json' : 'image/jpeg',
|
||||||
|
...(v ? { ETag: v } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return new Response('Not found', { status: 404 });
|
return new Response('Not found', { status: 404 });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import { readdir, stat, mkdir, writeFile, readFile, rm } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join, relative } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
|
||||||
|
// Server-side music library indexer — the platform counterpart of the app's music-index.ts. Walks the
|
||||||
|
// library and builds a cache tree that MIRRORS the library layout (so it syncs cleanly), using ffprobe
|
||||||
|
// for tags + duration and ffmpeg to compress covers for the phone. Output, per album folder at <rel>
|
||||||
|
// (relative to the Music root):
|
||||||
|
//
|
||||||
|
// DATA_PATH/music/cache/<rel>/meta.json { path, cover?, tracks: [{ file, title, artist, albumArtist,
|
||||||
|
// album, track, year, durationSec }] } (phone IndexMeta schema)
|
||||||
|
// DATA_PATH/music/cache/<rel>/cover.jpg compressed (<=600px, jpeg q5)
|
||||||
|
//
|
||||||
|
// A manifest (cache root) carries a per-album version `v` = hash of the album's source signature (track
|
||||||
|
// name+size+mtime, cover size+mtime). It drives BOTH incremental build (skip unchanged albums) and the
|
||||||
|
// phone's resync diff (fetch only changed `v`s).
|
||||||
|
|
||||||
|
const HOME = process.env.HOME_DIR ?? homedir();
|
||||||
|
const MUSIC_ROOT = join(HOME, 'Music');
|
||||||
|
const CACHE_ROOT = join(process.env.DATA_PATH ?? join(process.cwd(), 'data'), 'music', 'cache');
|
||||||
|
const MANIFEST_PATH = join(CACHE_ROOT, 'manifest.json');
|
||||||
|
|
||||||
|
const AUDIO_EXT = new Set(['mp3', 'flac', 'm4a', 'aac', 'ogg', 'opus', 'wav', 'wma', 'mp4']);
|
||||||
|
const COVER_FILES = ['folder.jpg', 'cover.jpg', 'folder.png', 'cover.png'];
|
||||||
|
const TRACK_CONCURRENCY = 6;
|
||||||
|
const COVER_MAX_PX = 600;
|
||||||
|
|
||||||
|
// ── Types (meta.json matches the app's IndexMeta/IndexTrack) ──
|
||||||
|
|
||||||
|
export type IndexTrack = {
|
||||||
|
file: string;
|
||||||
|
title?: string;
|
||||||
|
artist?: string;
|
||||||
|
albumArtist?: string;
|
||||||
|
album?: string;
|
||||||
|
track?: string;
|
||||||
|
year?: string;
|
||||||
|
durationSec?: number;
|
||||||
|
};
|
||||||
|
export type IndexMeta = { path: string; cover?: string; tracks: IndexTrack[] };
|
||||||
|
|
||||||
|
type ManifestEntry = { v: string; cover: boolean; tracks: number };
|
||||||
|
type Manifest = { version: number; generatedAt: number; albums: Record<string, ManifestEntry> };
|
||||||
|
|
||||||
|
export type IndexStatus = {
|
||||||
|
running: boolean;
|
||||||
|
startedAt: number | null;
|
||||||
|
finishedAt: number | null;
|
||||||
|
foldersScanned: number;
|
||||||
|
albumsBuilt: number; // rebuilt this run (changed)
|
||||||
|
albumsSkipped: number; // unchanged (v matched)
|
||||||
|
tracksIndexed: number;
|
||||||
|
coversSaved: number;
|
||||||
|
currentPath: string;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── In-memory manifest (loaded from disk on first access, updated on build) ──
|
||||||
|
|
||||||
|
let manifest: Manifest = { version: 1, generatedAt: 0, albums: {} };
|
||||||
|
let manifestLoaded = false;
|
||||||
|
|
||||||
|
async function loadManifest(): Promise<Manifest> {
|
||||||
|
if (manifestLoaded) return manifest;
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8')) as Manifest;
|
||||||
|
} catch {
|
||||||
|
manifest = { version: 1, generatedAt: 0, albums: {} };
|
||||||
|
}
|
||||||
|
manifestLoaded = true;
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getManifest(): Promise<Manifest> {
|
||||||
|
return loadManifest();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The version stamp for an album, for ETag/diff use (null if unknown). */
|
||||||
|
export async function albumVersion(rel: string): Promise<string | null> {
|
||||||
|
const m = await loadManifest();
|
||||||
|
return m.albums[rel]?.v ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status ──
|
||||||
|
|
||||||
|
const status: IndexStatus = {
|
||||||
|
running: false,
|
||||||
|
startedAt: null,
|
||||||
|
finishedAt: null,
|
||||||
|
foldersScanned: 0,
|
||||||
|
albumsBuilt: 0,
|
||||||
|
albumsSkipped: 0,
|
||||||
|
tracksIndexed: 0,
|
||||||
|
coversSaved: 0,
|
||||||
|
currentPath: '',
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getIndexStatus(): IndexStatus {
|
||||||
|
return { ...status };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
function isAudio(name: string): boolean {
|
||||||
|
const dot = name.lastIndexOf('.');
|
||||||
|
return dot >= 0 && AUDIO_EXT.has(name.slice(dot + 1).toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bounded-concurrency map preserving order. */
|
||||||
|
async function mapPool<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
||||||
|
const out: R[] = new Array(items.length);
|
||||||
|
let next = 0;
|
||||||
|
const worker = async () => {
|
||||||
|
while (true) {
|
||||||
|
const i = next++;
|
||||||
|
if (i >= items.length) break;
|
||||||
|
out[i] = await fn(items[i]!);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await Promise.all(Array.from({ length: Math.min(limit, items.length) || 0 }, worker));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ffprobeTrack(absPath: string, file: string): Promise<IndexTrack> {
|
||||||
|
try {
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
[
|
||||||
|
'ffprobe',
|
||||||
|
'-v',
|
||||||
|
'error',
|
||||||
|
'-print_format',
|
||||||
|
'json',
|
||||||
|
'-show_entries',
|
||||||
|
'format=duration:format_tags=title,artist,album,album_artist,track,date',
|
||||||
|
absPath,
|
||||||
|
],
|
||||||
|
{ stdout: 'pipe', stderr: 'ignore' },
|
||||||
|
);
|
||||||
|
const out = await new Response(proc.stdout).text();
|
||||||
|
await proc.exited;
|
||||||
|
const data = JSON.parse(out) as { format?: { duration?: string; tags?: Record<string, string> } };
|
||||||
|
const fmt = data.format ?? {};
|
||||||
|
// Tag keys vary in case across containers — normalize to lowercase.
|
||||||
|
const tags: Record<string, string> = {};
|
||||||
|
for (const [k, val] of Object.entries(fmt.tags ?? {})) tags[k.toLowerCase()] = val;
|
||||||
|
const durNum = fmt.duration ? Math.round(parseFloat(fmt.duration)) : undefined;
|
||||||
|
const yearMatch = tags.date?.match(/\d{4}/);
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
title: tags.title || undefined,
|
||||||
|
artist: tags.artist || undefined,
|
||||||
|
albumArtist: tags.album_artist || undefined,
|
||||||
|
album: tags.album || undefined,
|
||||||
|
track: tags.track || undefined,
|
||||||
|
year: yearMatch ? yearMatch[0] : undefined,
|
||||||
|
durationSec: Number.isFinite(durNum) ? durNum : undefined,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { file };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compress a cover to <=COVER_MAX_PX (no upscaling), jpeg q5. Returns true on success. */
|
||||||
|
async function compressCover(srcAbs: string, destAbs: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
[
|
||||||
|
'ffmpeg',
|
||||||
|
'-y',
|
||||||
|
'-i',
|
||||||
|
srcAbs,
|
||||||
|
'-vf',
|
||||||
|
`scale='min(iw,${COVER_MAX_PX})':'min(ih,${COVER_MAX_PX})':force_original_aspect_ratio=decrease`,
|
||||||
|
'-q:v',
|
||||||
|
'5',
|
||||||
|
destAbs,
|
||||||
|
],
|
||||||
|
{ stdout: 'ignore', stderr: 'ignore' },
|
||||||
|
);
|
||||||
|
await proc.exited;
|
||||||
|
return proc.exitCode === 0 && existsSync(destAbs);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build ──
|
||||||
|
|
||||||
|
export async function buildMusicIndex(): Promise<IndexStatus> {
|
||||||
|
if (status.running) return getIndexStatus();
|
||||||
|
Object.assign(status, {
|
||||||
|
running: true,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
finishedAt: null,
|
||||||
|
foldersScanned: 0,
|
||||||
|
albumsBuilt: 0,
|
||||||
|
albumsSkipped: 0,
|
||||||
|
tracksIndexed: 0,
|
||||||
|
coversSaved: 0,
|
||||||
|
currentPath: '',
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const prev = await loadManifest();
|
||||||
|
const next: Manifest = { version: 1, generatedAt: status.startedAt!, albums: {} };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(CACHE_ROOT, { recursive: true });
|
||||||
|
await walk(MUSIC_ROOT, prev, next);
|
||||||
|
|
||||||
|
// Prune cache dirs for albums that vanished from the library.
|
||||||
|
for (const rel of Object.keys(prev.albums)) {
|
||||||
|
if (!next.albums[rel]) {
|
||||||
|
await rm(join(CACHE_ROOT, rel), { recursive: true, force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(MANIFEST_PATH, JSON.stringify(next));
|
||||||
|
manifest = next;
|
||||||
|
manifestLoaded = true;
|
||||||
|
} catch (err) {
|
||||||
|
status.error = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
status.running = false;
|
||||||
|
status.finishedAt = Date.now();
|
||||||
|
status.currentPath = '';
|
||||||
|
}
|
||||||
|
return getIndexStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise<void> {
|
||||||
|
status.currentPath = relative(MUSIC_ROOT, dirAbs) || '.';
|
||||||
|
status.foldersScanned += 1;
|
||||||
|
|
||||||
|
let entries: import('node:fs').Dirent[];
|
||||||
|
try {
|
||||||
|
entries = await readdir(dirAbs, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = entries.filter((e) => e.isFile());
|
||||||
|
const subdirs = entries.filter((e) => e.isDirectory());
|
||||||
|
const audio = files.filter((e) => isAudio(e.name)).map((e) => e.name);
|
||||||
|
const coverName = COVER_FILES.find((c) => files.some((e) => e.name === c));
|
||||||
|
const rel = relative(MUSIC_ROOT, dirAbs); // '' at root
|
||||||
|
|
||||||
|
if (audio.length || coverName) {
|
||||||
|
// Source signature → version.
|
||||||
|
const sigParts: string[] = [];
|
||||||
|
for (const name of [...audio].sort()) {
|
||||||
|
const st = await stat(join(dirAbs, name)).catch(() => null);
|
||||||
|
if (st) sigParts.push(`${name}:${st.size}:${Math.round(st.mtimeMs)}`);
|
||||||
|
}
|
||||||
|
if (coverName) {
|
||||||
|
const st = await stat(join(dirAbs, coverName)).catch(() => null);
|
||||||
|
if (st) sigParts.push(`cover:${coverName}:${st.size}:${Math.round(st.mtimeMs)}`);
|
||||||
|
}
|
||||||
|
const v = Bun.hash(sigParts.join('|')).toString(16);
|
||||||
|
const cacheDir = join(CACHE_ROOT, rel);
|
||||||
|
const metaExists = existsSync(join(cacheDir, 'meta.json'));
|
||||||
|
|
||||||
|
if (prev.albums[rel]?.v === v && metaExists) {
|
||||||
|
status.albumsSkipped += 1;
|
||||||
|
} else {
|
||||||
|
await mkdir(cacheDir, { recursive: true });
|
||||||
|
|
||||||
|
if (coverName) {
|
||||||
|
const ok = await compressCover(join(dirAbs, coverName), join(cacheDir, 'cover.jpg'));
|
||||||
|
if (ok) status.coversSaved += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audio.length) {
|
||||||
|
const tracks = await mapPool(audio, TRACK_CONCURRENCY, async (name) => {
|
||||||
|
const t = await ffprobeTrack(join(dirAbs, name), name);
|
||||||
|
status.tracksIndexed += 1;
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
const meta: IndexMeta = { path: rel, cover: coverName ? 'cover.jpg' : undefined, tracks };
|
||||||
|
await writeFile(join(cacheDir, 'meta.json'), JSON.stringify(meta));
|
||||||
|
}
|
||||||
|
status.albumsBuilt += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
next.albums[rel] = { v, cover: !!coverName, tracks: audio.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const d of subdirs) await walk(join(dirAbs, d.name), prev, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Serving helpers (path-safe within CACHE_ROOT) ──
|
||||||
|
|
||||||
|
function resolveCachePath(rel: string, file: string): string | null {
|
||||||
|
const clean = rel.replace(/^\/+/, '');
|
||||||
|
const abs = join(CACHE_ROOT, clean, file);
|
||||||
|
if (abs !== CACHE_ROOT && !abs.startsWith(CACHE_ROOT + '/')) return null;
|
||||||
|
return abs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function metaFilePath(rel: string): string | null {
|
||||||
|
return resolveCachePath(rel, 'meta.json');
|
||||||
|
}
|
||||||
|
export function coverFilePath(rel: string): string | null {
|
||||||
|
return resolveCachePath(rel, 'cover.jpg');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user