music indexer: index videos (concerts/clips) in artist/album dirs

Videos (all phone-compatible .mp4, plus common containers) that live in an
artist or album folder are now indexed alongside audio:

- Move 'mp4' out of AUDIO_EXT into a new VIDEO_EXT (mp4/m4v/mkv/mov/webm/avi) —
  it was wrongly treated as an audio track before.
- ffprobeVideo captures file/title/durationSec/width/height per video.
- meta.json gains an optional `videos: IndexVideo[]`; a folder with only videos
  now still gets a meta.json. Manifest entries gain optional `videos: N`.
- Video files join the album version signature (changes bump `v` for resync).
- New `videosIndexed` status counter + resync-log line.

Location is inherent in the folder rel (always an artist/album dir), so no
extra location field is needed. MUSIC_API.md documents the videos field +
manifest count. No poster/thumbnail generation yet (folder cover is reused).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 14:16:45 +00:00
co-authored by Claude Opus 4.8
parent d317bfc983
commit d5b3dbb473
2 changed files with 113 additions and 14 deletions
+18 -4
View File
@@ -65,6 +65,7 @@ GET /api/music/manifest
"albums": { "albums": {
"Albums/AC-DC/[1980] Back in Black": { "v": "50856380f1ca8f9", "cover": true, "tracks": 10 }, "Albums/AC-DC/[1980] Back in Black": { "v": "50856380f1ca8f9", "cover": true, "tracks": 10 },
"DJ Sets/Dave Clarke": { "v": "a1b2c3d4e5f6a7b", "cover": false, "tracks": 3 }, "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 } "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 `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 **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 ### 2.2 Album metadata
@@ -96,11 +99,22 @@ Returns the album's `meta.json`. Sends `ETag: <v>`; a request with `If-None-Matc
"durationSec": 312 "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). All track/video fields except `file` are optional (absent when the tag/stream info is missing). `videos` is
To stream a track: `GET /api/music/stream?path=Music/<rel>/<file>`. omitted entirely when the folder has none.
To stream a track or video: `GET /api/music/stream?path=Music/<rel>/<file>` (byte-range; works for `.mp4`).
### 2.3 Cover ### 2.3 Cover
@@ -160,7 +174,7 @@ GET /api/music/reindex/status → IndexStatus snapshot
"running": true, "running": true,
"startedAt": 1785034701973, "finishedAt": null, "startedAt": 1785034701973, "finishedAt": null,
"foldersScanned": 45, "albumsBuilt": 12, "albumsSkipped": 3, "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", "currentPath": "Albums/AC-DC/[1980] Back in Black",
"error": null "error": null
} }
+95 -10
View File
@@ -9,7 +9,9 @@ import { homedir } from 'node:os';
// (relative to the Music root): // (relative to the Music root):
// //
// DATA_PATH/music/cache/<rel>/meta.json { path, cover?, tracks: [{ file, title, artist, albumArtist, // DATA_PATH/music/cache/<rel>/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/<rel>/cover.jpg compressed (<=600px, jpeg q5) // 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 // 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 CACHE_ROOT = join(process.env.DATA_PATH ?? join(process.cwd(), 'data'), 'music', 'cache');
const MANIFEST_PATH = join(CACHE_ROOT, 'manifest.json'); 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 COVER_FILES = ['folder.jpg', 'cover.jpg', 'folder.png', 'cover.png'];
const DISCO_FILE = '_discography.md'; const DISCO_FILE = '_discography.md';
const TRACK_CONCURRENCY = 6; const TRACK_CONCURRENCY = 6;
@@ -39,9 +45,16 @@ export type IndexTrack = {
year?: string; year?: string;
durationSec?: number; 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<string, ManifestEntry> }; type Manifest = { version: number; generatedAt: number; albums: Record<string, ManifestEntry> };
// ── Discography (artist-level _discography.md → album folder → normalized type) ── // ── Discography (artist-level _discography.md → album folder → normalized type) ──
@@ -88,6 +101,7 @@ export type IndexStatus = {
albumsBuilt: number; // rebuilt this run (changed) albumsBuilt: number; // rebuilt this run (changed)
albumsSkipped: number; // unchanged (v matched) albumsSkipped: number; // unchanged (v matched)
tracksIndexed: number; tracksIndexed: number;
videosIndexed: number;
coversSaved: number; coversSaved: number;
discographies: number; // artist discography.json files written discographies: number; // artist discography.json files written
currentPath: string; currentPath: string;
@@ -130,6 +144,7 @@ const status: IndexStatus = {
albumsBuilt: 0, albumsBuilt: 0,
albumsSkipped: 0, albumsSkipped: 0,
tracksIndexed: 0, tracksIndexed: 0,
videosIndexed: 0,
coversSaved: 0, coversSaved: 0,
discographies: 0, discographies: 0,
currentPath: '', currentPath: '',
@@ -146,6 +161,7 @@ export type IndexReport = {
skipped: number; skipped: number;
foldersScanned: number; foldersScanned: number;
tracksIndexed: number; tracksIndexed: number;
videosIndexed: number;
coversSaved: number; coversSaved: number;
discographies: number; discographies: number;
elapsedSec: number; elapsedSec: number;
@@ -160,6 +176,7 @@ export function buildReport(s: IndexStatus = status): IndexReport {
skipped: s.albumsSkipped, skipped: s.albumsSkipped,
foldersScanned: s.foldersScanned, foldersScanned: s.foldersScanned,
tracksIndexed: s.tracksIndexed, tracksIndexed: s.tracksIndexed,
videosIndexed: s.videosIndexed,
coversSaved: s.coversSaved, coversSaved: s.coversSaved,
discographies: s.discographies, discographies: s.discographies,
elapsedSec: Math.round(elapsed * 10) / 10, 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()); 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. */ /** Bounded-concurrency map preserving order. */
async function mapPool<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> { async function mapPool<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
const out: R[] = new Array(items.length); const out: R[] = new Array(items.length);
@@ -254,6 +276,44 @@ async function ffprobeTrack(absPath: string, file: string): Promise<IndexTrack>
} }
} }
async function ffprobeVideo(absPath: string, file: string): Promise<IndexVideo> {
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<string, string> };
streams?: Array<{ codec_type?: string; width?: number; height?: number }>;
};
const fmt = data.format ?? {};
const tags: Record<string, string> = {};
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. */ /** Compress a cover to <=COVER_MAX_PX (no upscaling), jpeg q5. Returns true on success. */
async function compressCover(srcAbs: string, destAbs: string): Promise<boolean> { async function compressCover(srcAbs: string, destAbs: string): Promise<boolean> {
try { try {
@@ -290,6 +350,7 @@ export async function buildMusicIndex(): Promise<IndexStatus> {
albumsBuilt: 0, albumsBuilt: 0,
albumsSkipped: 0, albumsSkipped: 0,
tracksIndexed: 0, tracksIndexed: 0,
videosIndexed: 0,
coversSaved: 0, coversSaved: 0,
discographies: 0, discographies: 0,
currentPath: '', currentPath: '',
@@ -326,7 +387,7 @@ export async function buildMusicIndex(): Promise<IndexStatus> {
console.error(`[music] resync failed after ${r.elapsedSec}s: ${r.error}`); console.error(`[music] resync failed after ${r.elapsedSec}s: ${r.error}`);
} else { } else {
console.log( 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<voi
const files = entries.filter((e) => e.isFile()); const files = entries.filter((e) => e.isFile());
const subdirs = entries.filter((e) => e.isDirectory()); const subdirs = entries.filter((e) => e.isDirectory());
const audio = files.filter((e) => isAudio(e.name)).map((e) => e.name); 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 coverName = COVER_FILES.find((c) => files.some((e) => e.name === c));
const rel = relative(MUSIC_ROOT, dirAbs); // '' at root const rel = relative(MUSIC_ROOT, dirAbs); // '' at root
const hasDisco = files.some((e) => e.name === DISCO_FILE); 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 // Source signature → version (includes _discography.md so regenerating it bumps v — isolated from
// the album meta/cover of the tracks underneath). // the album meta/cover of the tracks underneath).
const sigParts: string[] = []; const sigParts: string[] = [];
@@ -395,6 +459,10 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise<voi
const st = await stat(join(dirAbs, name)).catch(() => null); const st = await stat(join(dirAbs, name)).catch(() => null);
if (st) sigParts.push(`${name}:${st.size}:${Math.round(st.mtimeMs)}`); 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) { if (coverName) {
const st = await stat(join(dirAbs, coverName)).catch(() => null); const st = await stat(join(dirAbs, coverName)).catch(() => null);
if (st) sigParts.push(`cover:${coverName}:${st.size}:${Math.round(st.mtimeMs)}`); 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<voi
// was successfully cached before (prev.cover) — a source cover that can't be decoded (a junk .jpg) // was successfully cached before (prev.cover) — a source cover that can't be decoded (a junk .jpg)
// ends up cover:false and is never re-expected, so it doesn't rebuild every run. // ends up cover:false and is never re-expected, so it doesn't rebuild every run.
const expected = [ const expected = [
audio.length ? 'meta.json' : null, audio.length || video.length ? 'meta.json' : null,
prev.albums[rel]?.cover ? 'cover.jpg' : null, prev.albums[rel]?.cover ? 'cover.jpg' : null,
hasDisco ? 'discography.json' : null, hasDisco ? 'discography.json' : null,
].filter((f): f is string => f !== null); ].filter((f): f is string => f !== null);
@@ -427,14 +495,25 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise<voi
if (ok) status.coversSaved += 1; if (ok) status.coversSaved += 1;
} }
if (audio.length) { if (audio.length || video.length) {
const tracks = await mapPool(audio, TRACK_CONCURRENCY, async (name) => { const tracks = await mapPool(audio, TRACK_CONCURRENCY, async (name) => {
const t = await ffprobeTrack(join(dirAbs, name), name); const t = await ffprobeTrack(join(dirAbs, name), name);
status.tracksIndexed += 1; status.tracksIndexed += 1;
emitProgress(); emitProgress();
return t; 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)); await writeFile(join(cacheDir, 'meta.json'), JSON.stringify(meta));
} }
@@ -452,7 +531,13 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise<voi
status.albumsBuilt += 1; status.albumsBuilt += 1;
} }
next.albums[rel] = { v, cover: existsSync(coverJpg), tracks: audio.length, ...(hasDisco ? { disco: true } : {}) }; next.albums[rel] = {
v,
cover: existsSync(coverJpg),
tracks: audio.length,
...(video.length ? { videos: video.length } : {}),
...(hasDisco ? { disco: true } : {}),
};
} }
for (const d of subdirs) await walk(join(dirAbs, d.name), prev, next); for (const d of subdirs) await walk(join(dirAbs, d.name), prev, next);