diff --git a/crates/library/src/lib.rs b/crates/library/src/lib.rs index 858beab..c55b310 100644 --- a/crates/library/src/lib.rs +++ b/crates/library/src/lib.rs @@ -58,6 +58,25 @@ pub struct PlaylistTrack { pub art_path: Option, } +/// 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, + pub bit_depth: Option, + pub sample_rate: Option, + /// File extensions present, dominant first ("flac", "mp3", ...). + pub formats: Vec, + /// Newest play that met the caller's seconds floor. None = never played. + pub last_played_at: Option, +} + /// 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> { + 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, + rate: Option, + exts: Vec<(String, u32)>, + } + let mut by_album: std::collections::HashMap = std::collections::HashMap::new(); + let rows = stmt.query_map([], |r| { + Ok(( + r.get::<_, i64>(0)?, + r.get::<_, Option>(1)?, + r.get::<_, Option>(2)?, + r.get::<_, Option>(3)?, + r.get::<_, Option>(4)?, + r.get::<_, Option>(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 = 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> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 32f5fcb..ec8a11f 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -3997,6 +3997,22 @@ pub async fn library_albums(lib: State<'_, LibraryState>) -> Result, + min_secs: u32, +) -> Result, 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>, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0ced8eb..11a69e1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src/App.svelte b/src/App.svelte index a00b320..8d82c79 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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 diff --git a/src/lib/api.js b/src/lib/api.js index 4e67c3a..c05c60e 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -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. diff --git a/src/lib/localReleaseType.js b/src/lib/localReleaseType.js new file mode 100644 index 0000000..ef25b54 --- /dev/null +++ b/src/lib/localReleaseType.js @@ -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", +}; diff --git a/src/lib/mockLibrary.js b/src/lib/mockLibrary.js index e24fb2d..2b9f390 100644 --- a/src/lib/mockLibrary.js +++ b/src/lib/mockLibrary.js @@ -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 = `` + @@ -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") { diff --git a/src/views/Library.svelte b/src/views/Library.svelte index d4532ec..a977655 100644 --- a/src/views/Library.svelte +++ b/src/views/Library.svelte @@ -1,464 +1,2495 @@ -
-

Artists

- {#if libraryStore.albums.length === 0} -
-

Your music will live here

-

Download an album from Search and it lands here, tagged with art.

-
+ + +{#snippet cover(a, size)} + {#if a.art_path} + {#if coverUrls[a.id]} + + {:else} +
+ {/if} + {:else} + {@const g = genFor(a)} +
+ +
+ {/if} +{/snippet} + +{#snippet releaseCard(a, sectionKey)} +
toggleExpander(a)} + onkeydown={(e) => (e.key === "Enter" || e.key === " ") && toggleExpander(a)} + > +
+ {#if chipFor(a)}{chipFor(a)}{/if} + {@render cover(a, CARD_W)} + +
+
{a.title}
+
{subFor(a)}
+
+{/snippet} + +{#snippet gapCard(r)} +
+
+ {#if r.date?.slice(0, 4) || r.release_date?.slice(0, 4)} + {(r.release_date ?? r.date).slice(0, 4)} + {/if} + {#if r.cover} + {:else} -

{artists.length} artists

-
- {#each artists as artist (artist.name)} -
+
{r.title}
+
+ {#if fileRelease({ title: r.title, record_type: r.record_type, track_count: r.nb_tracks }) !== "other"} + {CAT_ONE[fileRelease({ title: r.title, record_type: r.record_type, track_count: r.nb_tracks })]} + {/if} +
+
+{/snippet} + +{#snippet expander(items, sectionKey)} +
+ + +
+ {#key openId} +
+
+
+ {#if openAlbum?.art_path && coverUrls[openAlbum.id]} + + {:else if openAlbum && !openAlbum.art_path} + {@const g = genFor(openAlbum)} + + {/if} +
+
+ {#if openAlbum?.art_path && coverUrls[openAlbum.id]} + + {:else if openAlbum} + {@const g = genFor(openAlbum)} +
+ +
+ {/if} +
+

+ {openAlbum?.title ?? ""} +

+

+ {openAlbum?.year ?? "no year"}{openTracks.length + ? ` · ${openTracks.length} tracks` + : openAlbum?.track_count + ? ` · ${openAlbum.track_count} tracks` + : ""}{openAlbum?.secs ? ` · ${dur(openAlbum.secs)}` : ""} +

+ {#if openAlbum && (openAlbum.bytes || openAlbum.avgKbps)} + +
+ {#if openAlbum.bytes} + {fmtBytes(openAlbum.bytes)}on disk + {/if} + {#if openAlbum.bytes && openAlbum.avgKbps}{/if} + {#if openAlbum.avgKbps} + {openAlbum.avgKbps}kbitrate + {/if} + {#if openAlbum.formats[0]} + {openAlbum.formats[0].toUpperCase()}{openAlbum.depth ? ` ${openAlbum.depth}` : ""} + {/if} +
+ {/if} +
+ + +
+
+
+ {#if openLoading} + {#if loadingVisible} +
+
+ Loading tracks +
+
+ {/if} + {:else if openTracks.length} +
+ # + Title + +
+ {#each openTracks as t, ti (t.id)} +
playOpen(ti)} + onkeydown={(e) => e.key === "Enter" && playOpen(ti)} + > + {isPlayingTrack(t) ? "♫" : (t.track_no ?? "·")} + + {t.title} + {#if t.bitrate_kbps} + {(t.path.split(".").pop() || "").toUpperCase()} {t.bitrate_kbps}k{t.bit_depth ? ` · ${t.bit_depth}-bit` : ""} + {/if} + + {t.duration_secs ? dur(t.duration_secs) : "—"} + + +
+ {/each} + {:else} +
+
No tracks found for this record
+
+ {/if} +
+
+
+ {/key} +
+
+{/snippet} + +{#snippet cardGrid(items, sectionKey)} +
+ {#each items as a, i (a.id)} + {@render releaseCard(a, sectionKey)} + {#if expanderIndex(items, sectionKey) === i} + {@render expander(items, sectionKey)} + {/if} + {/each} +
+{/snippet} + +{#snippet gapSection(name, list)} + {@const follow = followFor(name)} + {#if follow} + {@const missing = gapRows[name]} + {@const total = gapTotals[name]?.total} + {@const count = missing ? missing.length : (total ? Math.max(0, total - list.length) : null)} + {#if count !== 0} + + {/if} + {#if gapOpen[name] && missing?.length} + +
+
+ {#each missing as r (r.id)} + {@render gapCard(r)} + {/each} +
+
+ {/if} + {/if} +{/snippet} + +{#snippet shelfHead(name, list)} + {@const follow = followFor(name)} + {@const total = group === "artist" ? gapTotals[name]?.total : null} + {@const secs = list.reduce((n, a) => n + a.secs, 0)} + {@const actionable = group === "artist" && !!follow} + +
openCatalogArtist(follow.artistId, { id: follow.artistId, name }) : undefined} + onkeydown={actionable + ? (e) => (e.key === "Enter" || e.key === " ") && openCatalogArtist(follow.artistId, { id: follow.artistId, name }) + : undefined} + > + +
+
+ {name} + {#if group === "artist" && follow}{/if} +
+
+ {#if total}{list.length} of {total} released{:else}{list.length} {list.length === 1 ? "album" : "albums"}{/if}{#if secs} · {longDur(secs)}{/if} +
+ {#if total} + + {/if} +
+
+{/snippet} + +
+ {#if ambUrl} +
+
+ {/if} + + {#if libraryStore.albums.length === 0} +
+

Your music will live here

+

Download an album from Search and it lands here, tagged with art.

+
+ {:else} +
+
+

Library

+
+ + +
+
+
+ {totals.albums.toLocaleString()}{totals.albums === 1 ? "album" : "albums"} + + {totals.artists}{totals.artists === 1 ? "artist" : "artists"} + + {totals.tracks.toLocaleString()}tracks + {#if totals.secs} + + {longDur(totals.secs)}of music + {/if} + {#if totals.bytes} + + {fmtBytes(totals.bytes)}on disk + {/if} + {#if totals.never} + + {totals.never}never played + {/if} +
+
+ +
+
+ {#each [["shelves", "Shelves"], ["grid", "Grid"], ["list", "List"]] as [k, l] (k)} + {/each}
- {@const selected = artists.find((a) => a.name === expandedArtist)} -
- {#if selected} -
-

{selected.name}

{selected.albums.length} albums

- -
-
- {#each selected.albums as a (a.id)} - +
+ Group +
+ {#each Object.entries(GROUPS) as [k, g] (k)} + + {/each} +
+ Sort +
+ + {#if menuOpen} + - {#if inlineAlbum && selected.albums.some((a) => a.id === inlineAlbum.id)} -
-
-
-

{inlineAlbum.title}

-

{inlineAlbum.track_count} tracks{inlineAlbum.year ? ` · ${inlineAlbum.year}` : ""}

-
- -
-
- {#each inlineTracks as t, i (t.id)} - - {/each} -
-
- {/if} - {/if} -
+ {/if} +
+ +
+ {#if view === "list"} + +
+ RecordArtistTypeYear# + LengthFormatRateSizeAdded +
{/if} -
+
+ + {#if railItems.length} +
+ {#each railItems as [l, k] (k)} + + {/each} +
+ {/if} + + {#if corpus.length === 0} +
+ +

Nothing matches {q}

+
+ {:else if view === "shelves"} + {#each grouped as [name, list] (name)} + {@const shown = expandedShelves[name] ? list : list.slice(0, PER_SHELF)} +
+ {#if name}{@render shelfHead(name, list)}{/if} + {#if group === "artist"} + {#each splitByCategory(shown) as [label, part] (label ?? "all")} + {#if label} +
{label}{part.length}
+ {/if} + {@render cardGrid(part, `${name}:${label ?? "all"}`)} + {/each} + {:else} + {@render cardGrid(shown, name || "flat")} + {/if} + {#if list.length > shown.length} + + {/if} + {#if group === "artist"} + {@render gapSection(name, list)} + {/if} +
+ {/each} + {:else if view === "grid"} + {#each grouped as [name, list] (name)} +
+ {#if name} +
{name}{list.length}
+ {/if} + {@render cardGrid(list, name || "flat")} +
+ {/each} + {:else} +
+ {#each grouped as [name, list] (name)} +
+ {#if name} +
{name}{list.length}
+ {/if} + {#each list as a (a.id)} +
toggleExpander(a)} + onkeydown={(e) => (e.key === "Enter" || e.key === " ") && toggleExpander(a)} + > + + {#if a.art_path && coverUrls[a.id]} + + {:else if !a.art_path} + {@const g = genFor(a)} + + {:else} + + {/if} + + {a.title} + {a.artist} + {CAT_ONE[a.cat]} + {a.year ?? "—"} + {a.track_count || "—"} + {a.secs ? dur(a.secs) : "—"} + + {#if a.formats[0]} + + {a.formats[0].toUpperCase()}{#if a.depth}{a.depth}{/if} + + {:else}—{/if} + + {a.avgKbps ? `${a.avgKbps}k` : "—"} + {fmtBytes(a.bytes)} + {since(a.added_at)} +
+ {#if openId === a.id} + {@render expander([a], "list")} + {/if} + {/each} +
+ {/each} +
+ {/if} + {/if} +