diff --git a/MUSIC_API.md b/MUSIC_API.md index d2c2b120..a30f688a 100644 --- a/MUSIC_API.md +++ b/MUSIC_API.md @@ -65,6 +65,7 @@ GET /api/music/manifest "albums": { "Albums/AC-DC/[1980] Back in Black": { "v": "50856380f1ca8f9", "cover": true, "tracks": 10 }, "DJ Sets/Dave Clarke": { "v": "a1b2c3d4e5f6a7b", "cover": false, "tracks": 3 }, + "Albums/Metallica/[1989] Live Shit": { "v": "beefbeefbeefbee", "cover": true, "tracks": 0, "videos": 2 }, "Albums/AC-DC": { "v": "c0ffee1234567890", "cover": true, "tracks": 0, "disco": true } // … } @@ -72,7 +73,9 @@ GET /api/music/manifest ``` `404` if the index has never been built (see §3). Entries with **`tracks: 0`** are container folders (e.g. an **artist** folder). An entry with **`disco: true`** is an artist folder that has a discography — fetch its -grouping via `/discography` (§2.4). +grouping via `/discography` (§2.4). **`videos: N`** (optional) counts video files (concerts, clips) that live +directly in that artist/album folder — their per-file metadata is in that folder's `meta.json` (§2.2). A folder +may have any mix of `tracks`, `videos`, and `disco`. ### 2.2 Album metadata @@ -96,11 +99,22 @@ Returns the album's `meta.json`. Sends `ETag: `; a request with `If-None-Matc "durationSec": 312 } // … + ], + "videos": [ // present only for folders that contain video files + { + "file": "1989 - Seattle.mp4", // filename within the folder + "title": "Live Shit: Seattle", // from the container title tag, if any + "durationSec": 8130, + "width": 1280, + "height": 720 + } + // … ] } ``` -All track fields except `file` are optional (absent when the tag is missing). -To stream a track: `GET /api/music/stream?path=Music//`. +All track/video fields except `file` are optional (absent when the tag/stream info is missing). `videos` is +omitted entirely when the folder has none. +To stream a track or video: `GET /api/music/stream?path=Music//` (byte-range; works for `.mp4`). ### 2.3 Cover @@ -160,7 +174,7 @@ GET /api/music/reindex/status → IndexStatus snapshot "running": true, "startedAt": 1785034701973, "finishedAt": null, "foldersScanned": 45, "albumsBuilt": 12, "albumsSkipped": 3, - "tracksIndexed": 320, "coversSaved": 12, "discographies": 3, + "tracksIndexed": 320, "videosIndexed": 4, "coversSaved": 12, "discographies": 3, "currentPath": "Albums/AC-DC/[1980] Back in Black", "error": null } diff --git a/src/servers/sidecar/music/indexer.ts b/src/servers/sidecar/music/indexer.ts index e8c9f5f9..beab2ac5 100644 --- a/src/servers/sidecar/music/indexer.ts +++ b/src/servers/sidecar/music/indexer.ts @@ -9,7 +9,9 @@ import { homedir } from 'node:os'; // (relative to the Music root): // // DATA_PATH/music/cache//meta.json { path, cover?, tracks: [{ file, title, artist, albumArtist, -// album, track, year, durationSec }] } (phone IndexMeta schema) +// album, track, year, durationSec }], +// videos?: [{ file, title, durationSec, width, height }] } +// (phone IndexMeta schema) // DATA_PATH/music/cache//cover.jpg compressed (<=600px, jpeg q5) // // A manifest (cache root) carries a per-album version `v` = hash of the album's source signature (track @@ -21,7 +23,11 @@ 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 AUDIO_EXT = new Set(['mp3', 'flac', 'm4a', 'aac', 'ogg', 'opus', 'wav', 'wma']); +// Videos (concerts, clips) that live in an artist/album dir — all converted to phone-compatible .mp4, +// but index the common containers too. NOTE: 'mp4' is a VIDEO here (moved out of AUDIO_EXT); audio-in-mp4 +// belongs in .m4a. +const VIDEO_EXT = new Set(['mp4', 'm4v', 'mkv', 'mov', 'webm', 'avi']); const COVER_FILES = ['folder.jpg', 'cover.jpg', 'folder.png', 'cover.png']; const DISCO_FILE = '_discography.md'; const TRACK_CONCURRENCY = 6; @@ -39,9 +45,16 @@ export type IndexTrack = { year?: string; durationSec?: number; }; -export type IndexMeta = { path: string; cover?: string; tracks: IndexTrack[] }; +export type IndexVideo = { + file: string; + title?: string; + durationSec?: number; + width?: number; + height?: number; +}; +export type IndexMeta = { path: string; cover?: string; tracks: IndexTrack[]; videos?: IndexVideo[] }; -type ManifestEntry = { v: string; cover: boolean; tracks: number; disco?: boolean }; +type ManifestEntry = { v: string; cover: boolean; tracks: number; videos?: number; disco?: boolean }; type Manifest = { version: number; generatedAt: number; albums: Record }; // ── Discography (artist-level _discography.md → album folder → normalized type) ── @@ -88,6 +101,7 @@ export type IndexStatus = { albumsBuilt: number; // rebuilt this run (changed) albumsSkipped: number; // unchanged (v matched) tracksIndexed: number; + videosIndexed: number; coversSaved: number; discographies: number; // artist discography.json files written currentPath: string; @@ -130,6 +144,7 @@ const status: IndexStatus = { albumsBuilt: 0, albumsSkipped: 0, tracksIndexed: 0, + videosIndexed: 0, coversSaved: 0, discographies: 0, currentPath: '', @@ -146,6 +161,7 @@ export type IndexReport = { skipped: number; foldersScanned: number; tracksIndexed: number; + videosIndexed: number; coversSaved: number; discographies: number; elapsedSec: number; @@ -160,6 +176,7 @@ export function buildReport(s: IndexStatus = status): IndexReport { skipped: s.albumsSkipped, foldersScanned: s.foldersScanned, tracksIndexed: s.tracksIndexed, + videosIndexed: s.videosIndexed, coversSaved: s.coversSaved, discographies: s.discographies, elapsedSec: Math.round(elapsed * 10) / 10, @@ -200,6 +217,11 @@ function isAudio(name: string): boolean { return dot >= 0 && AUDIO_EXT.has(name.slice(dot + 1).toLowerCase()); } +function isVideo(name: string): boolean { + const dot = name.lastIndexOf('.'); + return dot >= 0 && VIDEO_EXT.has(name.slice(dot + 1).toLowerCase()); +} + /** Bounded-concurrency map preserving order. */ async function mapPool(items: T[], limit: number, fn: (item: T) => Promise): Promise { const out: R[] = new Array(items.length); @@ -254,6 +276,44 @@ async function ffprobeTrack(absPath: string, file: string): Promise } } +async function ffprobeVideo(absPath: string, file: string): Promise { + try { + const proc = Bun.spawn( + [ + 'ffprobe', + '-v', + 'error', + '-print_format', + 'json', + '-show_entries', + 'format=duration:format_tags=title:stream=codec_type,width,height', + 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 }; + streams?: Array<{ codec_type?: string; width?: number; height?: number }>; + }; + const fmt = data.format ?? {}; + const tags: Record = {}; + for (const [k, val] of Object.entries(fmt.tags ?? {})) tags[k.toLowerCase()] = val; + const vstream = (data.streams ?? []).find((s) => s.codec_type === 'video'); + const durNum = fmt.duration ? Math.round(parseFloat(fmt.duration)) : undefined; + return { + file, + title: tags.title || undefined, + durationSec: Number.isFinite(durNum) ? durNum : undefined, + width: vstream?.width, + height: vstream?.height, + }; + } 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 { try { @@ -290,6 +350,7 @@ export async function buildMusicIndex(): Promise { albumsBuilt: 0, albumsSkipped: 0, tracksIndexed: 0, + videosIndexed: 0, coversSaved: 0, discographies: 0, currentPath: '', @@ -326,7 +387,7 @@ export async function buildMusicIndex(): Promise { console.error(`[music] resync failed after ${r.elapsedSec}s: ${r.error}`); } else { console.log( - `[music] resync done — ${r.built} built, ${r.skipped} skipped, ${r.coversSaved} covers, ${r.tracksIndexed} tracks, ${r.foldersScanned} folders in ${r.elapsedSec}s`, + `[music] resync done — ${r.built} built, ${r.skipped} skipped, ${r.coversSaved} covers, ${r.tracksIndexed} tracks, ${r.videosIndexed} videos, ${r.foldersScanned} folders in ${r.elapsedSec}s`, ); } } @@ -382,12 +443,15 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise e.isFile()); const subdirs = entries.filter((e) => e.isDirectory()); const audio = files.filter((e) => isAudio(e.name)).map((e) => e.name); + // Videos in this folder (concerts/clips) — the folder is always an artist or album dir, so its rel is + // the video's location. Indexed alongside tracks; a folder with only videos still gets a meta.json. + const video = files.filter((e) => isVideo(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 const hasDisco = files.some((e) => e.name === DISCO_FILE); - if (audio.length || coverName || hasDisco) { + if (audio.length || video.length || coverName || hasDisco) { // Source signature → version (includes _discography.md so regenerating it bumps v — isolated from // the album meta/cover of the tracks underneath). const sigParts: string[] = []; @@ -395,6 +459,10 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise null); if (st) sigParts.push(`${name}:${st.size}:${Math.round(st.mtimeMs)}`); } + for (const name of [...video].sort()) { + const st = await stat(join(dirAbs, name)).catch(() => null); + if (st) sigParts.push(`vid:${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)}`); @@ -411,7 +479,7 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise f !== null); @@ -427,14 +495,25 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise { const t = await ffprobeTrack(join(dirAbs, name), name); status.tracksIndexed += 1; emitProgress(); return t; }); - const meta: IndexMeta = { path: rel, cover: existsSync(coverJpg) ? 'cover.jpg' : undefined, tracks }; + const videos = await mapPool(video, TRACK_CONCURRENCY, async (name) => { + const vmeta = await ffprobeVideo(join(dirAbs, name), name); + status.videosIndexed += 1; + emitProgress(); + return vmeta; + }); + const meta: IndexMeta = { + path: rel, + cover: existsSync(coverJpg) ? 'cover.jpg' : undefined, + tracks, + ...(videos.length ? { videos } : {}), + }; await writeFile(join(cacheDir, 'meta.json'), JSON.stringify(meta)); } @@ -452,7 +531,13 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise