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": {
|
||||||
"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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user