library: three views of the collection, and what every file actually is

Kyle picked this off an interactive prototype (research/library/) over a
long evening of passes, and the settings he landed on are baked in rather
than left as choices: records no catalogue knows about are ordinary
citizens with no marks at all, artwork is generated from a record's own
name when there is none, the albums you do not have are one collapsed
line that opens into full-size dimmed cards, an artist's shelf splits by
release type, the toolbar is a floating card, the index rail is always
on, the List view's column header is welded to that card, the entrance is
a wave of records, and cards are the artist page's own 150px.

The artists accordion is gone. In its place: three views (Shelves, Grid,
List), six groupings (Artist, Decade, Format, Type, Added, None), thirteen
sorts that are independent of the grouping, a live filter that moves every
number in the header, and a stat row that is derived rather than written
down.

A library page has to say what the files ARE, and library_albums only
carries id/artist/title/year/art/count/added. So the library crate gained
album_stats: one pass over tracks per call, aggregating length, bytes,
average bitrate, bit depth, sample rate and the dominant file extension
per album, plus the newest play that met a seconds floor the caller
passes (the same 45s the recents list uses — under it a play is a skip,
and a page that sorts by "never played first" must not count skips as
listening). Aggregated in Rust rather than SQL because the dominant
format needs the path's extension counted per album, which SQLite cannot
do without string gymnastics, and a library is a few thousand rows.

Filing a record as an album, an EP, a single, a live set, a compilation,
a soundtrack, a remix record or bonus material is not a new opinion:
localReleaseType.js carries CatalogArtist's own title heuristics verbatim
— the regexes are the tuned artifact — reduced to the branches whose
evidence a LOCAL record can carry. A file on disk has no MusicBrainz
release group, no editorial lead sentence and usually no record_type, so
the MB and description-hint branches are absent because their inputs
cannot exist. Under it sits the floor the prototype settled on: when the
classifier lands on "other" and the row carries no provider type, the two
facts a file always has decide — three tracks or under ten minutes is a
single, seven or under twenty-five an EP — with the session/demo check
re-run inside it so a five-track practice tape stays bonus material. It
is a copy, and it is marked as one at every function; unifying it with
the artist page is follow-up work, not something to smuggle into this.

Clicking a record opens CatalogArtist's inline expander, class for class,
with its numbers unchanged: the 460ms slide whose height is read live per
frame and kept monotonic (a captured height animates to a stale number
once the tracklist lands), the staggered waterfall that fades each part
to its OWN resting opacity, per-row entrances at min(i*16, 180), and a
same-row switch that keeps both panels alive while the stage's height
eases between them. What differs is the content, because a library knows
different facts: where the artist page shows Last.fm plays and listeners,
the rail shows size on disk and bitrate, where it offers Save an owned
record offers its folder, and each track carries what its file is.

Three things about the chrome are measured rather than typed, and each
one is a bug the prototype found first. The bar's height is read off the
bar, because a typed 50px parks anything pinned below it behind a bar
whose controls have wrapped; the bar's own minimum is a constant, because
publishing a measurement into the property that decides it is a ratchet.
The index rail's 34px gutter is reserved whenever the rail is on, drawn
or not, so no click on Group can change the width of the content column.
And the table's column template lives on the page rather than on the rows,
because a 1fr column floors at min-content and RECORD is wider than an
ellipsized title, so identical declarations on two elements resolve to
different widths; columns drop by tier from the page's measured width so
a record's name is never squeezed out.

The entrance is timed by POSITION, not by index. Indexing the document
spends the whole stagger on sections nobody is looking at — 38 shelves
means every visible one hits the cap and they all arrive in the same
frame, which has no relative motion in it and reads as no animation at
all. Delay comes from how far down and slightly across the pane a thing
sits, normalized to the topmost thing that is both moving and on screen,
and the delays are pulled forward over the next few frames as
content-visibility geometry lands (only forward, except while a thing is
still hidden, where a correction is invisible). The hero, the toolbar and
the column header never move: they are the page's furniture, and dealing
them in animated the whole window instead of the part that changed.

A rail click corrects until its target stops moving. content-visibility
makes the height of everything unpainted an estimate, so one scroll to a
section overshoots — measured on the prototype at up to 1143px, with the
page's own height moving 2260px during the jump.

Two bugs fixed on the way, both the same mistake: `>>` on an unsigned
32-bit hash goes negative above 2^31, so a generated track name indexed
past its word list and a generated cover's second hue produced an invalid
gradient. Both are `>>>`.

Deliberately not here. There is no Import button because there is no
backend command to import a folder — Rescan re-reads the library and its
stats, and a real importer is a backend feature. The "N of M released"
count and the missing-albums line appear only for artists you FOLLOW: a
follow carries an anchored artist id, and matching a local artist to a
provider by name is exactly what the identity rules forbid, so an
unfollowed shelf says what it holds and nothing more.

Verified against the running app on real files: 7 albums, 72 tracks,
1.8 GB on disk off the new command, and Maladroit opening to 13 tracks,
33:37, 279 MB, 1019k, FLAC 16/44.1 with per-track rates, its notch under
the card it belongs to and its row landing 10px under the pinned bar.
Verified headless against the mock library at scale: all eighteen
view-by-grouping combinations at one content width, the toolbar pinned
with zero drift, the joined header's columns matching the rows on both
edges, the expander opening from a card and from a row, morphing in place
within a row and closing without leaving its reserve behind, and the wave
moving 74 things with 34 distinct arrival times across 391ms while the
chrome stayed still.
This commit is contained in:
KeelrLeek committed 2026-08-05 18:02:40 -05:00
1 parent 10e4c75b33
commit f55052506a
8 files changed
+2762 -404

No files matched your search

+129
View File
@@ -58,6 +58,25 @@ pub struct PlaylistTrack {
pub art_path: Option<String>, pub art_path: Option<String>,
} }
/// Per-album aggregates of what the files carry — the Library page's numbers.
/// Everything here is derived from the tracks table plus the plays table;
/// nothing is stored, so a rescan or a new play is reflected on the next call.
#[derive(Debug, Clone, Serialize)]
pub struct AlbumStats {
pub album_id: i64,
/// Total running time of the album's tracks, seconds.
pub secs: i64,
/// Total size on disk, bytes.
pub bytes: i64,
pub avg_kbps: Option<u32>,
pub bit_depth: Option<u32>,
pub sample_rate: Option<u32>,
/// File extensions present, dominant first ("flac", "mp3", ...).
pub formats: Vec<String>,
/// Newest play that met the caller's seconds floor. None = never played.
pub last_played_at: Option<i64>,
}
/// One followed artist. Name and portrait are stored alongside the id so the /// One followed artist. Name and portrait are stored alongside the id so the
/// future home page can render "artists you follow" without paying a catalog /// future home page can render "artists you follow" without paying a catalog
/// request per artist — the row alone is enough to draw a card. /// request per artist — the row alone is enough to draw a card.
@@ -579,6 +598,116 @@ impl Library {
Ok(rows.filter_map(|r| r.ok()).collect()) Ok(rows.filter_map(|r| r.ok()).collect())
} }
/// Per-album aggregates for the Library page: what the FILES add up to.
/// One call for the whole library, because the page derives its stat row,
/// its Format grouping and four of its sorts (length, size, bitrate,
/// format) from these — asking per album would be N invokes on open.
///
/// `min_secs` is the same play floor the recents list uses (the frontend
/// passes its RECENTS_FLOOR_SECS): under it a play is a skip, and a page
/// that sorts by "never played first" must not count skips as listening.
pub fn album_stats(&self, min_secs: u32) -> Result<Vec<AlbumStats>> {
let conn = self.conn.lock().unwrap();
// Aggregate in Rust off one pass over tracks rather than in SQL:
// the dominant FORMAT needs the path's extension counted per album,
// which SQLite cannot do without string gymnastics, and a library is
// a few thousand rows — one iteration is nothing.
let mut stmt = conn.prepare(
"SELECT album_id, duration_secs, size, bitrate_kbps, bit_depth, sample_rate, path
FROM tracks",
)?;
struct Acc {
secs: i64,
bytes: i64,
kbps_sum: i64,
kbps_n: i64,
depth: Option<u32>,
rate: Option<u32>,
exts: Vec<(String, u32)>,
}
let mut by_album: std::collections::HashMap<i64, Acc> = std::collections::HashMap::new();
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, Option<i64>>(1)?,
r.get::<_, Option<i64>>(2)?,
r.get::<_, Option<i64>>(3)?,
r.get::<_, Option<u32>>(4)?,
r.get::<_, Option<u32>>(5)?,
r.get::<_, String>(6)?,
))
})?;
for row in rows {
let Ok((album_id, secs, size, kbps, depth, rate, path)) = row else { continue };
let acc = by_album.entry(album_id).or_insert(Acc {
secs: 0,
bytes: 0,
kbps_sum: 0,
kbps_n: 0,
depth: None,
rate: None,
exts: Vec::new(),
});
acc.secs += secs.unwrap_or(0);
acc.bytes += size.unwrap_or(0);
if let Some(k) = kbps {
acc.kbps_sum += k;
acc.kbps_n += 1;
}
acc.depth = acc.depth.max(depth);
acc.rate = acc.rate.max(rate);
let ext = std::path::Path::new(&path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
if !ext.is_empty() {
match acc.exts.iter_mut().find(|(e, _)| *e == ext) {
Some((_, n)) => *n += 1,
None => acc.exts.push((ext, 1)),
}
}
}
// Newest qualifying play per album. Exact album_id anchor only — a play
// written before the album_id column existed simply does not count,
// exactly as the recents list treats it.
let mut plays = conn.prepare(
"SELECT album_id, MAX(started_at) FROM plays
WHERE album_id IS NOT NULL AND seconds >= ?1
GROUP BY album_id",
)?;
let mut last_played: std::collections::HashMap<i64, i64> = std::collections::HashMap::new();
let play_rows = plays.query_map([min_secs], |r| {
Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?))
})?;
for row in play_rows {
if let Ok((id, at)) = row {
last_played.insert(id, at);
}
}
Ok(by_album
.into_iter()
.map(|(album_id, mut acc)| {
// Dominant format first, so `formats[0]` is the album's chip.
acc.exts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
AlbumStats {
album_id,
secs: acc.secs,
bytes: acc.bytes,
avg_kbps: if acc.kbps_n > 0 {
Some((acc.kbps_sum / acc.kbps_n) as u32)
} else {
None
},
bit_depth: acc.depth,
sample_rate: acc.rate,
formats: acc.exts.into_iter().map(|(e, _)| e).collect(),
last_played_at: last_played.get(&album_id).copied(),
}
})
.collect())
}
pub fn album_tracks(&self, album_id: i64) -> Result<Vec<Track>> { pub fn album_tracks(&self, album_id: i64) -> Result<Vec<Track>> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
+16
View File
@@ -3997,6 +3997,22 @@ pub async fn library_albums(lib: State<'_, LibraryState>) -> Result<Vec<library:
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
} }
/// Per-album aggregates (length, bytes, bitrate, formats, last played) for the
/// Library page — one call for the whole library. `min_secs` is the play floor:
/// the frontend passes the same RECENTS_FLOOR_SECS the recents list uses.
#[tauri::command]
pub async fn library_album_stats(
lib: State<'_, LibraryState>,
min_secs: u32,
) -> Result<Vec<library::AlbumStats>, String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || {
l.album_stats(min_secs).map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())?
}
#[tauri::command] #[tauri::command]
pub async fn album_tracks( pub async fn album_tracks(
lib: State<'_, LibraryState>, lib: State<'_, LibraryState>,
+1
View File
@@ -71,6 +71,7 @@ pub fn run() {
commands::player_state, commands::player_state,
commands::waveform, commands::waveform,
commands::library_albums, commands::library_albums,
commands::library_album_stats,
commands::album_tracks, commands::album_tracks,
commands::album_art, commands::album_art,
commands::refresh_album_art, commands::refresh_album_art,
+5
View File
@@ -68,6 +68,11 @@
"catalogAlbum", "catalogAlbum",
"catalog", "catalog",
"catalogDiscography", "catalogDiscography",
// The Library follows the discography's exception: its blurred
// ambience pins behind the glass and must begin at the very top of
// the pane, so the page carries its own top inset instead of taking
// padding-top here.
"library",
// Home is a "start BELOW the bar" page (App's search-page rule): it // Home is a "start BELOW the bar" page (App's search-page rule): it
// has no artwork to run under the frost, so it takes back // has no artwork to run under the frost, so it takes back
// --bar-h + air rather than --pane-pad. Its ambience wash is the one // --bar-h + air rather than --pane-pad. Its ambience wash is the one
+1
View File
@@ -83,6 +83,7 @@ export const playerControl = (action, value, rate) =>
export const playerState = () => invoke("player_state"); export const playerState = () => invoke("player_state");
export const waveform = (path, buckets) => invoke("waveform", { path, buckets }); export const waveform = (path, buckets) => invoke("waveform", { path, buckets });
export const libraryAlbums = () => invoke("library_albums"); export const libraryAlbums = () => invoke("library_albums");
export const libraryAlbumStats = (minSecs) => invoke("library_album_stats", { minSecs });
export const albumTracks = (albumId) => invoke("album_tracks", { albumId }); export const albumTracks = (albumId) => invoke("album_tracks", { albumId });
// `path` anchors the play to a library album via tracks.path (UNIQUE). Without // `path` anchors the play to a library album via tracks.path (UNIQUE). Without
// it the row still records, but with no album — so it cannot appear in recents. // it the row still records, but with no album — so it cannot appear in recents.
+133
View File
@@ -0,0 +1,133 @@
// How the LIBRARY files a record as an album, an EP, a single, a live set, a
// compilation, a soundtrack, a remix record or bonus material.
//
// The app already decides this, in CatalogArtist.svelte's `categoryFor` — with
// MusicBrainz secondary types, editorial description hints, edition-group
// folding, Deezer's record_type and a set of title heuristics tuned against
// real catalogues. This module is NOT a second opinion on any of that: it is
// the subset of that classifier whose evidence a LOCAL record can actually
// carry. A file on disk has no MusicBrainz release group, no editorial lead
// sentence, no edition_group and usually no record_type — the only evidence it
// brings is its title, its track count and its running time. So:
//
// · every title heuristic below is COPIED VERBATIM from CatalogArtist.svelte
// (the regexes are the tuned artifact — the comments there record what each
// clause caught). If one is improved there, re-copy it here; they are
// one-screen functions and the provenance comments say exactly where from.
// · the MB / description-hint / edition branches are omitted because their
// inputs cannot exist here — which is exactly how the approved Library
// prototype ran this classifier (with those maps empty).
// · under it sits the floor Kyle approved for the prototype: when the
// classifier lands on "other" AND the row carries no record_type, the two
// facts a file always has decide — ≤3 tracks or under 10 minutes is a
// single, ≤7 or under 25 an EP. `isSessionOrDemo` is re-checked inside the
// floor so a five-track practice tape stays bonus material.
//
// The right end state is one shared module that CatalogArtist imports too;
// that refactor is deliberate follow-up work, not something to smuggle into
// the Library landing (the artist page's copy is entangled with its harvest
// and identity machinery, and it is tuned and shipping).
// ---- title heuristics, verbatim from CatalogArtist.svelte ----------------
// CatalogArtist.svelte `COMPILATION_TITLE`.
const COMPILATION_TITLE = /\b(?:greatest\s+hits|best\s+of|very\s+best|anthology|singles\s+collection|the\s+singles|the\s+complete\s+recordings|complete\s+collection|collected\s+works|selected\s+works|retrospective|b[-\s]?sides\s+collection|box\s?set|\d+\s+album\s+set|the\s+essential|collection|the\s+hits|\d+["″]?\s+singles|complete\s+(?:\w+\s+)?recordings|bootleg\s+series)\b/i;
// CatalogArtist.svelte `isBoxSetTitle`. A slash-joined title of short
// single-word parts carrying a full album's worth of tracks is a box set of
// separate records, not a split single.
const isBoxSetTitle = (a) => {
if ((a.record_type ?? "").toLowerCase() !== "album") return false;
if ((a.track_count ?? 0) < 15) return false;
const parts = (a.title ?? "").split("/").map((part) => part.trim());
return parts.length >= 2 && parts.every((part) => /^[\p{L}\p{N}]{1,14}$/u.test(part));
};
// CatalogArtist.svelte `isCompilation`.
export const isCompilation = (a) => {
const type = (a.record_type ?? "").toLowerCase();
return type === "compile"
|| type === "compilation"
|| COMPILATION_TITLE.test(a.title ?? "")
|| isBoxSetTitle(a);
};
// CatalogArtist.svelte `isSoundtrack`.
export const isSoundtrack = (a) => a.is_soundtrack || /\b(?:soundtracks?|original\s+(?:motion\s+picture\s+)?score|film\s+score|motion\s+picture|music\s+from\s+(?:and\s+inspired\s+by\s+)?(?:the\s+)?(?:original\s+)?(?:films?|movies|television(?:\s+series)?|tv\s+(?:series|show)|video\s+game|\S+\s+(?:trilogy|documentary|saga)))\b/i.test(a.title ?? "");
// CatalogArtist.svelte `isSessionOrDemo`. Practice takes, rehearsals, demos,
// outtakes and radio-session odds — bonus material, not studio albums. Checked
// AFTER isLive so a genuine "BBC Sessions" live album stays in Live.
export const isSessionOrDemo = (a) => /\b(?:practice|rehearsals?|demos?|outtakes?|bbc\s+(?:recordings?|tracks?)|radio\s+sessions?|karaoke)\b/i.test(a.title ?? "");
// CatalogArtist.svelte `isRemix`.
export const isRemix = (a) => /\b(?:remix(?:es|ed)?|rework(?:s|ed)?|reconfigur(?:e|ed|ation)|rmx|mixes|dj\s+mix)\b/i.test(a.title ?? "");
// CatalogArtist.svelte `isLive`.
export const isLive = (a) => a.is_live || /\b(?:live\s+(?:at|from|in|on|around)|in\s+concert|unplugged|concert\s+recording|live\s+(?:album|recording)|bbc\s+sessions?|peel\s+sessions?|alive\s+(?:19|20)\d{2}|live\s+(?:19|20)\d{2}|concerts?\b|en\s+vivo|ao\s+vivo|en\s+directo|dal\s+vivo|live\s+aus)\b|(?:^|[\s(\[–—-])live(?:\s*[()\[\],:–—-]|$)/i.test(a.title ?? "");
// ---- the classifier -------------------------------------------------------
/** CatalogArtist.svelte `categoryFor`, reduced to the branches whose evidence
* a local record can carry, in the same order. (The MB-corroboration branch —
* where `page.albums` fed `titleContainsAnotherAlbum` — is gone with the MB
* hints that gated it, so no sibling list is needed here.) */
export function categoryForLocal(album) {
const type = (album.record_type ?? "").toLowerCase();
if (isSoundtrack(album)) return "soundtrack";
if (isLive(album)) return "live";
if (isCompilation(album)) return "compilation";
if (isRemix(album)) return "remix";
if (isSessionOrDemo(album)) return "other";
if (["album", "ep", "single"].includes(type)) return type;
return "other";
}
/** The floor under the classifier, exactly as approved on the prototype: only
* when it says "other" AND the row carries no provider record_type is there
* nothing left to reason from, and the two facts a file always has decide. */
export function localFallback(album) {
if (isSessionOrDemo(album)) return "other";
const n = album.track_count || (album.tracks ? album.tracks.length : 0);
const mins = (album.secs || 0) / 60;
if (!n) return "other";
if (n <= 3 || (mins && mins < 10)) return "single";
if (n <= 7 || (mins && mins < 25)) return "ep";
return "album";
}
/** File one album: the classifier, floored. */
export function fileRelease(album) {
const cat = categoryForLocal(album);
if (cat === "other" && !album.record_type) {
const guess = localFallback(album);
if (guess !== "other") return guess;
}
return cat;
}
// CatalogArtist.svelte `TAB_DEFS` — the categories, their labels and their
// ORDER. The Library's shelves-by-type and its Type grouping follow this list
// so the two pages file and order identically.
export const TAB_DEFS = [
{ key: "upcoming", label: "Upcoming", cats: ["upcoming"] },
{ key: "album", label: "Albums", cats: ["album"] },
{ key: "mixtape", label: "Mixtapes", cats: ["mixtape"] },
{ key: "ep", label: "EPs", cats: ["ep"] },
{ key: "single", label: "Singles", cats: ["single"] },
{ key: "live", label: "Live", cats: ["live"] },
{ key: "compilation", label: "Compilations", cats: ["compilation"] },
{ key: "soundtrack", label: "Soundtracks", cats: ["soundtrack"] },
{ key: "remix", label: "Remixes", cats: ["remix"] },
{ key: "other", label: "Other", cats: ["other"] },
];
export const CAT_ORDER = TAB_DEFS.map((t) => t.cats[0]);
export const CAT_LABEL = Object.fromEntries(TAB_DEFS.map((t) => [t.cats[0], t.label]));
/** Singular, for a card's sub line and the list view's Type column. */
export const CAT_ONE = {
upcoming: "Upcoming", album: "Album", mixtape: "Mixtape", ep: "EP", single: "Single",
live: "Live", compilation: "Compilation", soundtrack: "Soundtrack", remix: "Remix",
other: "Other",
};
+51 -9
View File
@@ -5,25 +5,56 @@
const ARTISTS = ["Aphex Twin","Boards of Canada","Caribou","Daft Punk","Eno Brian","Four Tet","Grimes","Holly Herndon","Jon Hopkins","Kraftwerk","LCD Soundsystem","Moderat","Nils Frahm","Oneohtrix","Portishead","Radiohead","Squarepusher","Tycho","Underworld","Vangelis","Wilco","Yello","Zero 7","Air","Bonobo","Burial","Clark","Deadmau5","Emancipator","Floating Points","Gold Panda","Helios","Kiasmos","Lorn","Moby","Nujabes","Ott","Plaid","Rival Consoles","Shpongle"]; const ARTISTS = ["Aphex Twin","Boards of Canada","Caribou","Daft Punk","Eno Brian","Four Tet","Grimes","Holly Herndon","Jon Hopkins","Kraftwerk","LCD Soundsystem","Moderat","Nils Frahm","Oneohtrix","Portishead","Radiohead","Squarepusher","Tycho","Underworld","Vangelis","Wilco","Yello","Zero 7","Air","Bonobo","Burial","Clark","Deadmau5","Emancipator","Floating Points","Gold Panda","Helios","Kiasmos","Lorn","Moby","Nujabes","Ott","Plaid","Rival Consoles","Shpongle"];
// Titles vary by slot so the Library's classifier has something real to file:
// a live set, demos, a remix record and a collection appear per artist cycle,
// and a few rows have NO art_path so the generated artwork can be judged.
const TITLE_SHAPES = [
(a, j) => `Album ${j + 1} of ${a}`,
(a) => `Live at the Roundhouse`,
(a) => `Four-Track Demos`,
(a) => `Remixes 2001`,
(a) => `Singles Collection`,
];
const albums = []; const albums = [];
let aid = 1; let aid = 1;
for (let i = 0; i < ARTISTS.length; i++) { for (let i = 0; i < ARTISTS.length; i++) {
const n = 2 + (i % 3); // 2-4 albums per artist const n = 2 + (i % 4); // 2-5 albums per artist
for (let j = 0; j < n; j++) { for (let j = 0; j < n; j++) {
const shape = j < 2 ? TITLE_SHAPES[0] : TITLE_SHAPES[(i + j) % TITLE_SHAPES.length];
albums.push({ albums.push({
id: aid, id: aid,
artist: ARTISTS[i], artist: ARTISTS[i],
title: `Album ${j + 1} of ${ARTISTS[i]}`, title: shape(ARTISTS[i], j),
year: 1995 + ((i + j) % 30), year: 1995 + ((i + j) % 30),
track_count: 10, track_count: j === 2 && i % 5 === 0 ? 3 : 8 + ((i + j) % 7),
art_path: `mock://art/${aid}`, art_path: (i + j) % 4 === 3 ? null : `mock://art/${aid}`,
artist_art_path: `mock://artist/${i}`, artist_art_path: `mock://artist/${i}`,
added_at: 1700000000 + i * 86400 * 9 + j * 86400 * 40,
path: `C:\\mock\\${i}\\${j}`, path: `C:\\mock\\${i}\\${j}`,
}); });
aid++; aid++;
} }
} }
// Per-album file aggregates, the same shape library_album_stats returns.
// Deterministic variety: a third FLAC 16/44.1, a few 24/96, the rest MP3.
function statsFor(a) {
const kind = a.id % 7;
const flac = kind === 0 || kind === 3 || kind === 5;
const hires = kind === 5;
const perTrack = flac ? (hires ? 120e6 : 38e6) : 9e6;
return {
album_id: a.id,
secs: a.track_count * (170 + (a.id % 90)),
bytes: Math.round(a.track_count * perTrack),
avg_kbps: flac ? (hires ? 2400 : 900) + (a.id % 120) : 192 + (a.id % 4) * 32,
bit_depth: flac ? (hires ? 24 : 16) : null,
sample_rate: flac ? (hires ? 96000 : 44100) : null,
formats: [flac ? "flac" : "mp3"],
last_played_at: a.id % 3 === 0 ? null : 1723000000 - (a.id % 40) * 86400,
};
}
function svgArt(hue, label) { function svgArt(hue, label) {
const s = const s =
`<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256">` + `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256">` +
@@ -33,15 +64,23 @@ function svgArt(hue, label) {
} }
function tracksFor(albumId) { function tracksFor(albumId) {
const artist = albums.find((a) => a.id === albumId)?.artist ?? "?"; const album = albums.find((a) => a.id === albumId);
return Array.from({ length: 10 }, (_, k) => ({ const artist = album?.artist ?? "?";
const s = album ? statsFor(album) : null;
const ext = s?.formats[0] ?? "mp3";
const n = album?.track_count ?? 10;
return Array.from({ length: n }, (_, k) => ({
id: albumId * 100 + k, id: albumId * 100 + k,
album_id: albumId,
title: `Track ${k + 1}`, title: `Track ${k + 1}`,
artist, artist,
track_no: k + 1, track_no: k + 1,
duration_secs: 120 + k * 17, duration_secs: 120 + ((albumId + k) % 9) * 23,
bitrate_kbps: 320, bitrate_kbps: s?.avg_kbps ?? 320,
path: `C:\\mock\\t${albumId}-${k}.mp3`, bit_depth: s?.bit_depth ?? null,
sample_rate: s?.sample_rate ?? null,
size: Math.round((s?.bytes ?? 40e6) / n),
path: `C:\\mock\\t${albumId}-${k}.${ext}`,
})); }));
} }
@@ -57,6 +96,9 @@ const enabled = (() => {
export function mockInvoke(cmd, args) { export function mockInvoke(cmd, args) {
if (!enabled) return undefined; if (!enabled) return undefined;
if (cmd === "library_albums") return Promise.resolve(albums); if (cmd === "library_albums") return Promise.resolve(albums);
if (cmd === "library_album_stats") return Promise.resolve(albums.map(statsFor));
if (cmd === "followed_artists") return Promise.resolve([]);
if (cmd === "recent_albums") return Promise.resolve([]);
if (cmd === "album_tracks") if (cmd === "album_tracks")
return new Promise((r) => setTimeout(() => r(tracksFor(args.albumId)), 60)); return new Promise((r) => setTimeout(() => r(tracksFor(args.albumId)), 60));
if (cmd === "album_art") { if (cmd === "album_art") {
+2426 -395
View File
File diff suppressed because it is too large. Load diff