music: index artist discographies (album → release type) for the app
Each Albums/<Artist>/_discography.md (author-maintained source of truth, never modified) is compiled into a per-artist discography.json in the cache = album folder → normalized release type (Studio/Live/Compilation/Single/EP/…), so the player can split an artist's album list into sections. - indexer.ts: parse the md table, normalize the Type (EP?→EP, Compilation (VA)→ Compilation, …), write discography.json. The artist folder's `v` now includes _discography.md so regenerating it re-syncs just that small JSON (isolated from the albums' meta/cover). Manifest gains `disco: true` on such entries. Also fixed the skip check to require all expected outputs to exist, so artist/ cover-only folders no longer rebuild every run. New `discographies` counter. - sidecar: GET /discography?path=<artist rel> (ETag/304), documented in the contract header. - MUSIC_API.md: §2.4 + manifest disco flag + resync algorithm updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+38
-5
@@ -64,12 +64,15 @@ GET /api/music/manifest
|
|||||||
"generatedAt": 1785034701973, // ms; when the index was last built
|
"generatedAt": 1785034701973, // ms; when the index was last built
|
||||||
"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/AC-DC": { "v": "c0ffee1234567890", "cover": true, "tracks": 0, "disco": true }
|
||||||
// …
|
// …
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
`404` if the index has never been built (see §3).
|
`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).
|
||||||
|
|
||||||
### 2.2 Album metadata
|
### 2.2 Album metadata
|
||||||
|
|
||||||
@@ -107,6 +110,36 @@ GET /api/music/cover?path=<rel>
|
|||||||
Compressed JPEG (≤600px on the long edge, ~30–80 KB). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
Compressed JPEG (≤600px on the long edge, ~30–80 KB). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
||||||
Only meaningful when the manifest entry has `"cover": true`.
|
Only meaningful when the manifest entry has `"cover": true`.
|
||||||
|
|
||||||
|
### 2.4 Discography (artist album grouping)
|
||||||
|
|
||||||
|
For artist folders (manifest entry with `"disco": true`), this returns a map of **album folder → release
|
||||||
|
type**, so the player can split an artist's album list into sections (Studio, Live, Compilation, Single, EP…).
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/music/discography?path=<artist rel> e.g. path=Albums/AC-DC
|
||||||
|
```
|
||||||
|
Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"artist": "Anthrax",
|
||||||
|
"albums": {
|
||||||
|
"[1984] Fistful Of Metal": "Studio",
|
||||||
|
"[1985] Armed And Dangerous": "EP",
|
||||||
|
"[1994] The Island Years": "Live",
|
||||||
|
"[1991] Attack Of The Killer B's": "Compilation"
|
||||||
|
// …
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Keys are **album folder names** (`[year] title`) — they map 1:1 to the artist's album folders, i.e. the
|
||||||
|
last path segment of that album's manifest `<rel>`. Group the artist's albums by looking each up here.
|
||||||
|
- **Types** are a normalized set: `Studio`, `Live`, `Compilation`, `Single`, `EP`, `Soundtrack`, `Remix`,
|
||||||
|
`DJ-Mix`, `Demo`, `Mixtape`, `Bootleg`, `Other` (unknown values pass through as-is). The player defines
|
||||||
|
section order.
|
||||||
|
- An album folder **not present** here has no classification → put it in an "Other"/uncategorized section.
|
||||||
|
- Source of truth is each artist's `_discography.md` (author-maintained); this JSON is derived from it and
|
||||||
|
re-generated whenever that file changes (its `v` bumps independently of the albums' `meta`/`cover`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Building / refreshing the index
|
## 3. Building / refreshing the index
|
||||||
@@ -127,7 +160,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,
|
"tracksIndexed": 320, "coversSaved": 12, "discographies": 3,
|
||||||
"currentPath": "Albums/AC-DC/[1980] Back in Black",
|
"currentPath": "Albums/AC-DC/[1980] Back in Black",
|
||||||
"error": null
|
"error": null
|
||||||
}
|
}
|
||||||
@@ -153,7 +186,7 @@ data: {"albums":15,"built":12,"skipped":3,"foldersScanned":45,"tracksIndexed":32
|
|||||||
`IndexReport` (the `done` payload):
|
`IndexReport` (the `done` payload):
|
||||||
```jsonc
|
```jsonc
|
||||||
{ "albums": 15, "built": 12, "skipped": 3, "foldersScanned": 45,
|
{ "albums": 15, "built": 12, "skipped": 3, "foldersScanned": 45,
|
||||||
"tracksIndexed": 320, "coversSaved": 12, "elapsedSec": 37.2, "error": null }
|
"tracksIndexed": 320, "coversSaved": 12, "discographies": 3, "elapsedSec": 37.2, "error": null }
|
||||||
```
|
```
|
||||||
|
|
||||||
> First build of a large library takes a few minutes; re-runs are near-instant (unchanged albums skip via `v`).
|
> First build of a large library takes a few minutes; re-runs are near-instant (unchanged albums skip via `v`).
|
||||||
@@ -167,7 +200,7 @@ Keep the last `manifest.albums` you synced. On resync:
|
|||||||
1. `GET /api/music/manifest`.
|
1. `GET /api/music/manifest`.
|
||||||
2. For each `<rel>` in the new manifest:
|
2. For each `<rel>` in the new manifest:
|
||||||
- **new**, or **`v` differs** from your stored copy → fetch `GET /meta?path=<rel>` (+ `GET /cover?path=<rel>`
|
- **new**, or **`v` differs** from your stored copy → fetch `GET /meta?path=<rel>` (+ `GET /cover?path=<rel>`
|
||||||
if `cover:true`); store them under your local `<rel>/`.
|
if `cover:true`, + `GET /discography?path=<rel>` if `disco:true`); store them under your local `<rel>/`.
|
||||||
- **`v` unchanged** → **skip** (no download).
|
- **`v` unchanged** → **skip** (no download).
|
||||||
3. For each `<rel>` you have locally that's **absent** from the new manifest → delete it.
|
3. For each `<rel>` you have locally that's **absent** from the new manifest → delete it.
|
||||||
4. Save the new manifest as your baseline.
|
4. Save the new manifest as your baseline.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
albumVersion,
|
albumVersion,
|
||||||
metaFilePath,
|
metaFilePath,
|
||||||
coverFilePath,
|
coverFilePath,
|
||||||
|
discographyFilePath,
|
||||||
onIndexProgress,
|
onIndexProgress,
|
||||||
buildReport,
|
buildReport,
|
||||||
} from './indexer';
|
} from './indexer';
|
||||||
@@ -30,9 +31,14 @@ const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
|||||||
//
|
//
|
||||||
// GET /stream?path=<home-relative> audio with Range→206 (Content-Range/Length/Accept-Ranges)
|
// GET /stream?path=<home-relative> audio with Range→206 (Content-Range/Length/Accept-Ranges)
|
||||||
// + `X-Audio-Duration` (seconds, ffprobe). 400/404/416.
|
// + `X-Audio-Duration` (seconds, ffprobe). 400/404/416.
|
||||||
// GET /manifest { version, generatedAt, albums: { "<rel>": { v, cover, tracks } } }
|
// GET /manifest { version, generatedAt, albums: { "<rel>": { v, cover, tracks, disco? } } }
|
||||||
// GET /meta?path=<rel> album meta.json (IndexMeta). ETag: <v>; If-None-Match → 304.
|
// GET /meta?path=<rel> album meta.json (IndexMeta). ETag: <v>; If-None-Match → 304.
|
||||||
// GET /cover?path=<rel> compressed cover.jpg. ETag: <v>; If-None-Match → 304.
|
// GET /cover?path=<rel> compressed cover.jpg. ETag: <v>; If-None-Match → 304.
|
||||||
|
// GET /discography?path=<artist rel> artist discography.json (only where manifest entry has
|
||||||
|
// disco:true) = { artist, albums: { "<[year] album folder>":
|
||||||
|
// "<Type>" } }, Type ∈ Studio/Live/Compilation/Single/EP/…
|
||||||
|
// ETag: <v>; If-None-Match → 304. Source: each artist folder's
|
||||||
|
// _discography.md (normalized; the md itself is never modified).
|
||||||
// POST /reindex start an async build; returns IndexStatus (running: true).
|
// POST /reindex start an async build; returns IndexStatus (running: true).
|
||||||
// GET /reindex/status IndexStatus snapshot.
|
// GET /reindex/status IndexStatus snapshot.
|
||||||
// GET /reindex/stream SSE. Triggers a build if idle (`?trigger=0` = watch-only).
|
// GET /reindex/stream SSE. Triggers a build if idle (`?trigger=0` = watch-only).
|
||||||
@@ -135,21 +141,21 @@ const server = Bun.serve({
|
|||||||
// ── Sync surface ──
|
// ── Sync surface ──
|
||||||
if (url.pathname === '/manifest') return json(await getManifest());
|
if (url.pathname === '/manifest') return json(await getManifest());
|
||||||
|
|
||||||
if (url.pathname === '/meta' || url.pathname === '/cover') {
|
if (url.pathname === '/meta' || url.pathname === '/cover' || url.pathname === '/discography') {
|
||||||
const rel = url.searchParams.get('path');
|
const rel = url.searchParams.get('path');
|
||||||
if (rel === null) return new Response('path is required', { status: 400 });
|
if (rel === null) return new Response('path is required', { status: 400 });
|
||||||
const isMeta = url.pathname === '/meta';
|
const spec = {
|
||||||
const file = isMeta ? metaFilePath(rel) : coverFilePath(rel);
|
'/meta': { file: metaFilePath(rel), type: 'application/json' },
|
||||||
if (!file) return new Response('Invalid path', { status: 400 });
|
'/cover': { file: coverFilePath(rel), type: 'image/jpeg' },
|
||||||
if (!(await Bun.file(file).exists())) return new Response('Not found', { status: 404 });
|
'/discography': { file: discographyFilePath(rel), type: 'application/json' },
|
||||||
|
}[url.pathname]!;
|
||||||
|
if (!spec.file) return new Response('Invalid path', { status: 400 });
|
||||||
|
if (!(await Bun.file(spec.file).exists())) return new Response('Not found', { status: 404 });
|
||||||
|
|
||||||
const v = await albumVersion(rel);
|
const v = await albumVersion(rel);
|
||||||
if (v && req.headers.get('if-none-match') === v) return new Response(null, { status: 304 });
|
if (v && req.headers.get('if-none-match') === v) return new Response(null, { status: 304 });
|
||||||
return new Response(Bun.file(file), {
|
return new Response(Bun.file(spec.file), {
|
||||||
headers: {
|
headers: { 'Content-Type': spec.type, ...(v ? { ETag: v } : {}) },
|
||||||
'Content-Type': isMeta ? 'application/json' : 'image/jpeg',
|
|
||||||
...(v ? { ETag: v } : {}),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { readdir, stat, mkdir, writeFile, readFile, rm } from 'node:fs/promises';
|
import { readdir, stat, mkdir, writeFile, readFile, rm } from 'node:fs/promises';
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import { join, relative } from 'node:path';
|
import { join, relative, basename } from 'node:path';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
|
|
||||||
// Server-side music library indexer — the platform counterpart of the app's music-index.ts. Walks the
|
// Server-side music library indexer — the platform counterpart of the app's music-index.ts. Walks the
|
||||||
@@ -23,6 +23,7 @@ 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', 'mp4']);
|
||||||
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 TRACK_CONCURRENCY = 6;
|
const TRACK_CONCURRENCY = 6;
|
||||||
const COVER_MAX_PX = 600;
|
const COVER_MAX_PX = 600;
|
||||||
|
|
||||||
@@ -40,9 +41,45 @@ export type IndexTrack = {
|
|||||||
};
|
};
|
||||||
export type IndexMeta = { path: string; cover?: string; tracks: IndexTrack[] };
|
export type IndexMeta = { path: string; cover?: string; tracks: IndexTrack[] };
|
||||||
|
|
||||||
type ManifestEntry = { v: string; cover: boolean; tracks: number };
|
type ManifestEntry = { v: string; cover: boolean; tracks: 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) ──
|
||||||
|
|
||||||
|
/** Canonical section for a Type cell. The source _discography.md is NEVER modified — this only cleans
|
||||||
|
* the derived JSON so the phone groups into tidy sections. Unknown types pass through trimmed. */
|
||||||
|
function normalizeType(raw: string): string {
|
||||||
|
const t = raw.trim().replace(/\?+$/, '').trim(); // "EP?" → "EP", "Single?" → "Single"
|
||||||
|
const lower = t.toLowerCase();
|
||||||
|
if (lower.startsWith('compilation')) return 'Compilation'; // "Compilation (VA)" → "Compilation"
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
studio: 'Studio',
|
||||||
|
live: 'Live',
|
||||||
|
single: 'Single',
|
||||||
|
ep: 'EP',
|
||||||
|
soundtrack: 'Soundtrack',
|
||||||
|
remix: 'Remix',
|
||||||
|
'dj-mix': 'DJ-Mix',
|
||||||
|
djmix: 'DJ-Mix',
|
||||||
|
demo: 'Demo',
|
||||||
|
mixtape: 'Mixtape',
|
||||||
|
bootleg: 'Bootleg',
|
||||||
|
other: 'Other',
|
||||||
|
};
|
||||||
|
return map[lower] ?? t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the md table into { "[year] album" (folder name) → normalized type }. */
|
||||||
|
function parseDiscography(mdText: string): Record<string, string> {
|
||||||
|
const albums: Record<string, string> = {};
|
||||||
|
for (const line of mdText.split('\n')) {
|
||||||
|
const m = line.match(/^\|\s*(\d{4})\s*\|\s*(.+?)\s*\|\s*([^|]+?)\s*\|/);
|
||||||
|
if (!m) continue; // header/separator/non-rows don't match (Year isn't 4 digits)
|
||||||
|
albums[`[${m[1]}] ${m[2]}`] = normalizeType(m[3]!);
|
||||||
|
}
|
||||||
|
return albums;
|
||||||
|
}
|
||||||
|
|
||||||
export type IndexStatus = {
|
export type IndexStatus = {
|
||||||
running: boolean;
|
running: boolean;
|
||||||
startedAt: number | null;
|
startedAt: number | null;
|
||||||
@@ -52,6 +89,7 @@ export type IndexStatus = {
|
|||||||
albumsSkipped: number; // unchanged (v matched)
|
albumsSkipped: number; // unchanged (v matched)
|
||||||
tracksIndexed: number;
|
tracksIndexed: number;
|
||||||
coversSaved: number;
|
coversSaved: number;
|
||||||
|
discographies: number; // artist discography.json files written
|
||||||
currentPath: string;
|
currentPath: string;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
};
|
};
|
||||||
@@ -93,6 +131,7 @@ const status: IndexStatus = {
|
|||||||
albumsSkipped: 0,
|
albumsSkipped: 0,
|
||||||
tracksIndexed: 0,
|
tracksIndexed: 0,
|
||||||
coversSaved: 0,
|
coversSaved: 0,
|
||||||
|
discographies: 0,
|
||||||
currentPath: '',
|
currentPath: '',
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
@@ -108,6 +147,7 @@ export type IndexReport = {
|
|||||||
foldersScanned: number;
|
foldersScanned: number;
|
||||||
tracksIndexed: number;
|
tracksIndexed: number;
|
||||||
coversSaved: number;
|
coversSaved: number;
|
||||||
|
discographies: number;
|
||||||
elapsedSec: number;
|
elapsedSec: number;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
};
|
};
|
||||||
@@ -121,6 +161,7 @@ export function buildReport(s: IndexStatus = status): IndexReport {
|
|||||||
foldersScanned: s.foldersScanned,
|
foldersScanned: s.foldersScanned,
|
||||||
tracksIndexed: s.tracksIndexed,
|
tracksIndexed: s.tracksIndexed,
|
||||||
coversSaved: s.coversSaved,
|
coversSaved: s.coversSaved,
|
||||||
|
discographies: s.discographies,
|
||||||
elapsedSec: Math.round(elapsed * 10) / 10,
|
elapsedSec: Math.round(elapsed * 10) / 10,
|
||||||
error: s.error,
|
error: s.error,
|
||||||
};
|
};
|
||||||
@@ -250,6 +291,7 @@ export async function buildMusicIndex(): Promise<IndexStatus> {
|
|||||||
albumsSkipped: 0,
|
albumsSkipped: 0,
|
||||||
tracksIndexed: 0,
|
tracksIndexed: 0,
|
||||||
coversSaved: 0,
|
coversSaved: 0,
|
||||||
|
discographies: 0,
|
||||||
currentPath: '',
|
currentPath: '',
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
@@ -300,8 +342,11 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise<voi
|
|||||||
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
|
||||||
|
|
||||||
if (audio.length || coverName) {
|
const hasDisco = files.some((e) => e.name === DISCO_FILE);
|
||||||
// Source signature → version.
|
|
||||||
|
if (audio.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[] = [];
|
const sigParts: string[] = [];
|
||||||
for (const name of [...audio].sort()) {
|
for (const name of [...audio].sort()) {
|
||||||
const st = await stat(join(dirAbs, name)).catch(() => null);
|
const st = await stat(join(dirAbs, name)).catch(() => null);
|
||||||
@@ -311,11 +356,23 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise<voi
|
|||||||
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)}`);
|
||||||
}
|
}
|
||||||
|
if (hasDisco) {
|
||||||
|
const st = await stat(join(dirAbs, DISCO_FILE)).catch(() => null);
|
||||||
|
if (st) sigParts.push(`disco:${st.size}:${Math.round(st.mtimeMs)}`);
|
||||||
|
}
|
||||||
const v = Bun.hash(sigParts.join('|')).toString(16);
|
const v = Bun.hash(sigParts.join('|')).toString(16);
|
||||||
const cacheDir = join(CACHE_ROOT, rel);
|
const cacheDir = join(CACHE_ROOT, rel);
|
||||||
const metaExists = existsSync(join(cacheDir, 'meta.json'));
|
|
||||||
|
|
||||||
if (prev.albums[rel]?.v === v && metaExists) {
|
// Skip only if v matches AND every expected output already exists (fixes cover-only/artist folders
|
||||||
|
// that have no meta.json from rebuilding every run).
|
||||||
|
const expected = [
|
||||||
|
audio.length ? 'meta.json' : null,
|
||||||
|
coverName ? 'cover.jpg' : null,
|
||||||
|
hasDisco ? 'discography.json' : null,
|
||||||
|
].filter((f): f is string => f !== null);
|
||||||
|
const outputsExist = expected.every((f) => existsSync(join(cacheDir, f)));
|
||||||
|
|
||||||
|
if (prev.albums[rel]?.v === v && outputsExist) {
|
||||||
status.albumsSkipped += 1;
|
status.albumsSkipped += 1;
|
||||||
} else {
|
} else {
|
||||||
await mkdir(cacheDir, { recursive: true });
|
await mkdir(cacheDir, { recursive: true });
|
||||||
@@ -335,10 +392,22 @@ async function walk(dirAbs: string, prev: Manifest, next: Manifest): Promise<voi
|
|||||||
const meta: IndexMeta = { path: rel, cover: coverName ? 'cover.jpg' : undefined, tracks };
|
const meta: IndexMeta = { path: rel, cover: coverName ? 'cover.jpg' : undefined, tracks };
|
||||||
await writeFile(join(cacheDir, 'meta.json'), JSON.stringify(meta));
|
await writeFile(join(cacheDir, 'meta.json'), JSON.stringify(meta));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasDisco) {
|
||||||
|
try {
|
||||||
|
const md = await readFile(join(dirAbs, DISCO_FILE), 'utf8');
|
||||||
|
const disco = { artist: basename(dirAbs), albums: parseDiscography(md) };
|
||||||
|
await writeFile(join(cacheDir, 'discography.json'), JSON.stringify(disco));
|
||||||
|
status.discographies += 1;
|
||||||
|
} catch {
|
||||||
|
/* skip discography on failure */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
status.albumsBuilt += 1;
|
status.albumsBuilt += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
next.albums[rel] = { v, cover: !!coverName, tracks: audio.length };
|
next.albums[rel] = { v, cover: !!coverName, tracks: audio.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);
|
||||||
@@ -359,3 +428,6 @@ export function metaFilePath(rel: string): string | null {
|
|||||||
export function coverFilePath(rel: string): string | null {
|
export function coverFilePath(rel: string): string | null {
|
||||||
return resolveCachePath(rel, 'cover.jpg');
|
return resolveCachePath(rel, 'cover.jpg');
|
||||||
}
|
}
|
||||||
|
export function discographyFilePath(rel: string): string | null {
|
||||||
|
return resolveCachePath(rel, 'discography.json');
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user