forked from Keelrleek/SoulStream
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:
1 parent
10e4c75b33
commit
f55052506a
8 files changed
+2762
-404
No files matched your search
@@ -58,6 +58,25 @@ pub struct PlaylistTrack {
|
||||
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
|
||||
/// future home page can render "artists you follow" without paying a catalog
|
||||
/// 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())
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
|
||||
@@ -3997,6 +3997,22 @@ pub async fn library_albums(lib: State<'_, LibraryState>) -> Result<Vec<library:
|
||||
.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]
|
||||
pub async fn album_tracks(
|
||||
lib: State<'_, LibraryState>,
|
||||
|
||||
@@ -71,6 +71,7 @@ pub fn run() {
|
||||
commands::player_state,
|
||||
commands::waveform,
|
||||
commands::library_albums,
|
||||
commands::library_album_stats,
|
||||
commands::album_tracks,
|
||||
commands::album_art,
|
||||
commands::refresh_album_art,
|
||||
|
||||
@@ -68,6 +68,11 @@
|
||||
"catalogAlbum",
|
||||
"catalog",
|
||||
"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
|
||||
// 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
|
||||
|
||||
@@ -83,6 +83,7 @@ export const playerControl = (action, value, rate) =>
|
||||
export const playerState = () => invoke("player_state");
|
||||
export const waveform = (path, buckets) => invoke("waveform", { path, buckets });
|
||||
export const libraryAlbums = () => invoke("library_albums");
|
||||
export const libraryAlbumStats = (minSecs) => invoke("library_album_stats", { minSecs });
|
||||
export const albumTracks = (albumId) => invoke("album_tracks", { albumId });
|
||||
// `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.
|
||||
|
||||
@@ -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
@@ -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"];
|
||||
|
||||
// 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 = [];
|
||||
let aid = 1;
|
||||
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++) {
|
||||
const shape = j < 2 ? TITLE_SHAPES[0] : TITLE_SHAPES[(i + j) % TITLE_SHAPES.length];
|
||||
albums.push({
|
||||
id: aid,
|
||||
artist: ARTISTS[i],
|
||||
title: `Album ${j + 1} of ${ARTISTS[i]}`,
|
||||
title: shape(ARTISTS[i], j),
|
||||
year: 1995 + ((i + j) % 30),
|
||||
track_count: 10,
|
||||
art_path: `mock://art/${aid}`,
|
||||
track_count: j === 2 && i % 5 === 0 ? 3 : 8 + ((i + j) % 7),
|
||||
art_path: (i + j) % 4 === 3 ? null : `mock://art/${aid}`,
|
||||
artist_art_path: `mock://artist/${i}`,
|
||||
added_at: 1700000000 + i * 86400 * 9 + j * 86400 * 40,
|
||||
path: `C:\\mock\\${i}\\${j}`,
|
||||
});
|
||||
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) {
|
||||
const s =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256">` +
|
||||
@@ -33,15 +64,23 @@ function svgArt(hue, label) {
|
||||
}
|
||||
|
||||
function tracksFor(albumId) {
|
||||
const artist = albums.find((a) => a.id === albumId)?.artist ?? "?";
|
||||
return Array.from({ length: 10 }, (_, k) => ({
|
||||
const album = albums.find((a) => a.id === albumId);
|
||||
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,
|
||||
album_id: albumId,
|
||||
title: `Track ${k + 1}`,
|
||||
artist,
|
||||
track_no: k + 1,
|
||||
duration_secs: 120 + k * 17,
|
||||
bitrate_kbps: 320,
|
||||
path: `C:\\mock\\t${albumId}-${k}.mp3`,
|
||||
duration_secs: 120 + ((albumId + k) % 9) * 23,
|
||||
bitrate_kbps: s?.avg_kbps ?? 320,
|
||||
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) {
|
||||
if (!enabled) return undefined;
|
||||
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")
|
||||
return new Promise((r) => setTimeout(() => r(tracksFor(args.albumId)), 60));
|
||||
if (cmd === "album_art") {
|
||||
|
||||
+2426
-395
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user