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:
+18
-4
@@ -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: <v>`; 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/<rel>/<file>`.
|
||||
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/<rel>/<file>` (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
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ import { homedir } from 'node:os';
|
||||
// (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)
|
||||
// album, track, year, durationSec }],
|
||||
// videos?: [{ file, title, durationSec, width, height }] }
|
||||
// (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
|
||||
@@ -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<string, ManifestEntry> };
|
||||
|
||||
// ── 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<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
||||
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. */
|
||||
async function compressCover(srcAbs: string, destAbs: string): Promise<boolean> {
|
||||
try {
|
||||
@@ -290,6 +350,7 @@ export async function buildMusicIndex(): Promise<IndexStatus> {
|
||||
albumsBuilt: 0,
|
||||
albumsSkipped: 0,
|
||||
tracksIndexed: 0,
|
||||
videosIndexed: 0,
|
||||
coversSaved: 0,
|
||||
discographies: 0,
|
||||
currentPath: '',
|
||||
@@ -326,7 +387,7 @@ export async function buildMusicIndex(): Promise<IndexStatus> {
|
||||
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<voi
|
||||
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);
|
||||
// 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<voi
|
||||
const st = await stat(join(dirAbs, name)).catch(() => 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<voi
|
||||
// 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.
|
||||
const expected = [
|
||||
audio.length ? 'meta.json' : null,
|
||||
audio.length || video.length ? 'meta.json' : null,
|
||||
prev.albums[rel]?.cover ? 'cover.jpg' : null,
|
||||
hasDisco ? 'discography.json' : 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 (audio.length) {
|
||||
if (audio.length || video.length) {
|
||||
const tracks = await mapPool(audio, TRACK_CONCURRENCY, async (name) => {
|
||||
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<voi
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user