/* The library, as the phone reads it: albums from library.db, the recents from the play history, follows and playlists. One store, loaded once at launch; the pieces that change (recents) are re-read when they do. */ import * as api from "../lib/api.js"; import { listen } from "../lib/events.js"; export const lib = $state({ albums: [], recents: [], // library rows only, with an album_id follows: [], playlists: [], /** album id → { secs, bytes, avg_kbps, bit_depth, sample_rate, formats, last_played_at } — the desktop's library_album_stats */ stats: {}, version: "", username: null, loaded: false, error: "", }); const tracksCache = new Map(); // album id -> Promise /** An album's tracks, cached for the session (a record's rows do not * change under the phone). */ export function tracksOf(id) { if (!tracksCache.has(id)) { tracksCache.set( id, api.albumTracks(id).catch((e) => { tracksCache.delete(id); throw e; }), ); } return tracksCache.get(id); } export function albumById(id) { return lib.albums.find((a) => a.id === id) || null; } /** Newest first — "recently added" everywhere. */ export function newest() { return lib.albums.slice().sort((a, b) => b.added_at - a.added_at); } /** An artist's records, newest year first. */ export function albumsBy(artist) { const k = String(artist || "").toLowerCase(); return lib.albums .filter((a) => a.artist.toLowerCase() === k) .sort((x, y) => (y.year || 0) - (x.year || 0) || y.added_at - x.added_at); } /** Every artist in the library, biggest first, with the portrait the * library already holds (artist_art_path) when it has one. Casing splits in * the tags ("King Gizzard & The/the Lizard Wizard") merge here. */ export function artists() { const m = new Map(); for (const a of lib.albums) { const k = a.artist.toLowerCase(); let e = m.get(k); if (!e) { e = { name: a.artist, albums: 0, tracks: 0, art: null, cover: null }; m.set(k, e); } e.albums++; e.tracks += a.track_count || 0; if (!e.art && a.artist_art_path) e.art = a.artist_art_path; if (!e.cover && a.art_path) e.cover = a.art_path; } return [...m.values()].sort((x, y) => y.albums - x.albums || y.tracks - x.tracks || x.name.localeCompare(y.name)); } export function isFollowed(name) { const k = String(name || "").toLowerCase(); return lib.follows.some((f) => f.name.toLowerCase() === k); } export function followedEntry(name) { const k = String(name || "").toLowerCase(); return lib.follows.find((f) => f.name.toLowerCase() === k) || null; } export async function refreshFollows() { try { lib.follows = (await api.followedArtists()) || []; } catch { /* keep what we had */ } } /** Follow or unfollow, by the catalog identity; the list is re-read. */ export async function toggleFollow(artistId, name, portrait = null) { const cur = followedEntry(name); if (cur) await api.unfollowArtist(cur.artist_id); else await api.followArtist(artistId, name, portrait ?? null); await refreshFollows(); } export async function refreshRecents() { try { const rows = await api.recentAlbums(14); lib.recents = (rows || []).filter((r) => r.kind === "library" && r.album_id != null); } catch { /* the rail simply stays as it was */ } } /** A download the pipeline just filed, a rescan, an import: the backend * says so (soulseek:library-changed) and the albums are re-read. */ let watching = false; export async function watchLibrary() { if (watching) return; watching = true; let timer = null; try { /* two subscriptions that never depended on one another. `listen` is an invoke like any other, so one behind the other is two bridge round trips at launch for nothing. */ await Promise.all([ listen("soulseek:library-changed", () => { clearTimeout(timer); timer = setTimeout(async () => { try { lib.albums = await api.libraryAlbums(); } catch { /* keep */ } await refreshRecents(); void refreshStats(); }, 600); }), /* a playlist mutation (the desktop's commands emit it) re-lists them */ listen("soulseek:playlists-changed", () => { void refreshPlaylists(); }), ]); } catch { /* not a phone */ } } export async function loadLibrary() { /* Five reads that never depended on one another. Each is a spawn_blocking SQLite read behind the library's single `conn: Mutex`, so the queries still take their turn — what is saved is five bridge round trips collapsing into one. Every call keeps the handling it had. The one thing that could have moved is `lib.error`, which has two writers and used to be settled by the order they were awaited in; the two messages are carried out of the round and applied in that same order instead, so a failing library still shows the library's message and not app_status's, no matter which of them answers last. Nothing is dropped, nothing new is asked for, and `lib.loaded` still goes up only once all five have settled. */ const [statusErr, albumsErr, , follows, playlists] = await Promise.all([ api.appStatus().then((st) => { lib.version = st?.version ?? ""; lib.username = st?.connected_as ?? null; return null; }, (e) => String(e)), api.libraryAlbums().then((rows) => { lib.albums = rows; return null; }, (e) => String(e)), refreshRecents(), // writes lib.recents itself, and swallows its own failure api.followedArtists().then((rows) => rows || [], () => []), api.listPlaylists().then((rows) => rows || [], () => []), ]); if (statusErr) lib.error = statusErr; if (albumsErr) lib.error = albumsErr; lib.follows = follows; lib.playlists = playlists; lib.loaded = true; void refreshStats(); } /** The files' own aggregates per record — one call for the whole library * (the desktop's RECENTS_FLOOR_SECS, 45, is the floor a play must reach * to count as the last one). */ export async function refreshStats() { try { const rows = (await api.libraryAlbumStats(45)) || []; lib.stats = Object.fromEntries(rows.map((r) => [r.album_id, r])); } catch { /* the cards stay quiet */ } } /** "FLAC 24/96" for a lossless record, "MP3 320k" for a lossy one. */ export function statLabel(st) { if (!st || !st.formats?.length) return ""; const f = String(st.formats[0]).toUpperCase(); if (st.bit_depth && st.sample_rate) return f + " " + st.bit_depth + "/" + (st.sample_rate % 1000 === 0 ? st.sample_rate / 1000 : (st.sample_rate / 1000).toFixed(1)); return st.avg_kbps ? f + " " + st.avg_kbps + "k" : f; } export async function refreshPlaylists() { try { lib.playlists = (await api.listPlaylists()) || []; } catch { /* keep what we have */ } }