// Round twenty-five: the catalog's numbers — Popular asked on its own as // well as with the page, a listener count for the rows a record's own // artist anchor could not reach, and an artist's hero photo chosen on this // phone (set_artist_hero is pick_artist_hero's install step split out and // shared, the way round fifteen did it for a playlist's cover). const fs = require('fs'); const root = process.env.SS_ROOT || 'F:/Coding/SoulStream/'; const DIR = 'C:/Users/Kyle/AppData/Local/Temp/claude/F--Coding-SoulStream/5488514a-11e8-482a-b0a0-20cb511ee110/scratchpad/'; let p, s; const load = (file) => { p = file; s = fs.readFileSync(root + p, 'utf8'); }; const save = () => fs.writeFileSync(root + p, s); const once = (a) => { const i = s.indexOf(a); if (i < 0) throw new Error('missing in ' + p + ': ' + a.slice(0, 90)); if (s.indexOf(a, i + 1) >= 0) throw new Error('ambiguous in ' + p + ': ' + a.slice(0, 90)); return i; }; const rep = (a, b) => { once(a); s = s.replace(a, () => b); }; /* the long insertions live beside this script; the file's own last newline is not part of the text */ const ins = (name) => fs.readFileSync(DIR + 'ins-r25-' + name + '.txt', 'utf8').replace(/\r?\n$/, ''); /* commands.rs is CRLF (and carries a BOM) — anchor and insert in ITS line endings, or the file comes back mixed */ const CRLF = String.fromCharCode(13) + String.fromCharCode(10); const crlf = (t) => t.replace(/\r?\n/g, CRLF); const repRs = (a, b) => rep(crlf(a), crlf(b)); // ── the backend: the desktop's install step, split out and shared ── load('src-tauri/src/commands.rs'); repRs(` let Some(source) = rx.await.unwrap_or(None) else { return Ok(None); }; let source = std::path::PathBuf::from(source);`, ` let Some(source) = rx.await.unwrap_or(None) else { return Ok(None); }; install_artist_hero(&app, artist_id, source).map(Some) } /// A picked image — the desktop's dialog, or a photo the phone's storage /// plugin already copied into the app's cache — validated and written as /// this artist's chosen hero. fn install_artist_hero( app: &tauri::AppHandle, artist_id: u64, source: String, ) -> Result { let source = std::path::PathBuf::from(source);`); repRs(` let (ext, _) = artist_image_format(&source)?; let dir = artist_hero_dir(&app)?;`, ` let (ext, _) = artist_image_format(&source)?; let dir = artist_hero_dir(app)?;`); repRs(` let mut hero = load_artist_hero_override(&app, artist_id)? .ok_or_else(|| "The imported hero could not be reopened".to_string())?; hero.width = width; hero.height = height; Ok(Some(hero)) }`, ` let mut hero = load_artist_hero_override(app, artist_id)? .ok_or_else(|| "The imported hero could not be reopened".to_string())?; hero.width = width; hero.height = height; Ok(hero) } /// The phone's door onto the same import: a photo already on disk (the /// storage plugin's picker copied it into the app's cache) becomes this /// artist's chosen hero. The dialog above stays the desktop's. #[tauri::command] pub async fn set_artist_hero( app: tauri::AppHandle, artist_id: u64, path: String, ) -> Result { install_artist_hero(&app, artist_id, path) }`); save(); load('src-tauri/src/lib.rs'); rep(` commands::pick_artist_hero,`, ` commands::pick_artist_hero, commands::set_artist_hero,`); save(); // ── the wrapper ── load('src/lib/api.js'); rep(`export const clearArtistHero = (artistId) => invoke("clear_artist_hero", { artistId });`, `export const clearArtistHero = (artistId) => invoke("clear_artist_hero", { artistId }); // The phone's hero door: a photo the storage plugin already copied into the // app's cache, installed the way the desktop's dialog pick installs one. export const setArtistHero = (artistId, path) => invoke("set_artist_hero", { artistId, path });`); save(); // ── a data: URL is already the image ── // A custom hero comes back inside catalog_artist as a data: URL, and the // art gate sent everything that is not https to the thumbnail cache, which // cannot open one: the banner, the ground and the accent all went blank. load('src/mobile/art.svelte.js'); rep(` if (art[path] !== undefined) return Promise.resolve(art[path]);`, ` if (art[path] !== undefined) return Promise.resolve(art[path]); /* a data: URL is already the image — an artist's chosen hero comes back from Rust as one, and the thumbnail fetch would throw it away */ if (path.startsWith("data:")) { art[path] = path; return Promise.resolve(path); }`); save(); // ── the store ── load('src/mobile/catalog.svelte.js'); rep(`import * as api from "../lib/api.js";`, `import * as api from "../lib/api.js"; import { note } from "./notice.svelte.js";`); rep(` /** "artist\\u0000title" → { status, info: { description, genre, label, source } } */ albumInfo: {}, });`, ` /** "artist\\u0000title" → { status, info: { description, genre, label, source } } */ albumInfo: {}, /** artist id → { status, tracks } — Popular on its own, ahead of the page */ popular: {}, /** catalog album id → { status, counts: { trackId: listeners } } — the * deferred Last.fm pass for the rows the record's page had no count for */ listeners: {}, });`); rep(`/** A portrait for a byline — the same identity rule, search only. */`, ins('popular')); rep(`export function lookupAlbum(id) {`, ins('store')); save(); // ── the artist page: Popular's fast lane ── load('src/mobile/screens/Artist.svelte'); rep(` import { catalog, lookupArtist, artistKey, fans, yearOf, looseTitle, loadExtras, picture } from "../catalog.svelte.js";`, ` import { catalog, lookupArtist, artistKey, fans, yearOf, looseTitle, loadExtras, loadPopular, picture } from "../catalog.svelte.js";`); rep(` $effect(() => { const id = page?.artist?.id; const nm = page?.artist?.name || name; if (id != null) untrack(() => loadExtras(id, nm)); });`, ` $effect(() => { const id = page?.artist?.id; const nm = page?.artist?.name || name; if (id != null) untrack(() => loadExtras(id, nm)); }); /* Popular's own lane, asked the moment an id is known — from the search's own row, or from the identity search — so the section can paint before the full page is built */ $effect(() => { const id = artistId; if (id != null) untrack(() => loadPopular(id)); });`); rep(` const popularAll = $derived(page?.popular_tracks ?? []);`, ` /* the page's own list once it lands (the same Last.fm order, from the same inputs); the fast lane's until then, and for an artist whose page came back with no Popular at all */ const fastPopular = $derived(artistId != null ? catalog.popular[artistId]?.tracks ?? [] : []); const popularAll = $derived(page?.popular_tracks?.length ? page.popular_tracks : fastPopular);`); save(); // ── the catalog record: a count for the rows the anchor could not reach ── load('src/mobile/screens/CatalogAlbum.svelte'); rep(` import { catalog, lookupAlbum, lookupArtist, artistKey, seedFor, picture, yearOf, looseTitle, loadAlbumInfo, infoKey } from "../catalog.svelte.js";`, ` import { catalog, lookupAlbum, lookupArtist, artistKey, seedFor, picture, yearOf, looseTitle, loadAlbumInfo, infoKey, loadTrackListeners } from "../catalog.svelte.js";`); rep(` const peak = $derived(rows.reduce((m, t) => Math.max(m, t.listeners || 0), 0));`, ` /* the rows the record's own artist anchor found no count for — a feature's guest, a compilation's stranger — get the deferred pass by their OWN artist id; a record's stats are anchored on its artist alone and cannot reach them */ $effect(() => { const rs = rows, k = id; if (rs.length) untrack(() => loadTrackListeners(k, rs)); }); const extraCounts = $derived(catalog.listeners[id]?.counts ?? {}); const listenersOf = (t) => t.listeners ?? extraCounts[t.id] ?? null; const peak = $derived(rows.reduce((m, t) => Math.max(m, listenersOf(t) || 0), 0));`); rep(` {#if showCount}{fmtCount(t.listeners)}{/if}`, ` {#if showCount}{fmtCount(listenersOf(t))}{/if}`); save(); // ── the artist page's own overflow sheet (the bar's ⋯ fell through to home) ── load('src/mobile/HalfSheet.svelte'); rep(` import { catalog, yearOf } from "./catalog.svelte.js";`, ` import { catalog, yearOf, artistKey, picture, heroFromPhoto, heroClear } from "./catalog.svelte.js";`); rep(` if (t.n === "catalogAlbum") return "catalogAlbum-more";`, ` if (t.n === "catalogAlbum") return "catalogAlbum-more"; if (t.n === "artist") return "artist-more";`); rep(` {:else if kind === "home-more"}`, ` {:else if kind === "artist-more"} {@const nm = top().c.name} {@const ent = catalog.artists[artistKey(nm)] ?? null} {@const pg = ent?.page ?? null} {@const aid = pg?.artist?.id ?? ent?.id ?? null}
{#if picture(pg?.artist?.picture)}{:else}{/if} {nm}{pg?.albums?.length ? pg.albums.length + " releases on the catalog" : "Artist"}
{#if aid != null} {@render row("image", "Choose hero photo…", "a wide picture from this phone, 1920×800 or larger", act(() => heroFromPhoto(aid, nm).catch(oops)))} {#if pg?.banner_custom} {@render row("x", "Remove custom hero", "the catalog's own photos come back", act(() => heroClear(aid, nm).catch(oops)))} {/if}
{/if} {@render row("settings", "Artwork settings", "covers and portraits, in You", act(() => go("you")))}
{:else if kind === "home-more"}`); save(); // ── You → Album artwork: the desktop's portrait refresh ── load('src/mobile/screens/You.svelte'); rep(` let refetching = $state(false);`, ` let refetching = $state(false); let refreshingArtists = $state(false);`); rep(` const ROWS = [ ["ambience", "image", "Ambience", "the artwork in play, blurred, under every screen"],`, ins('you')); rep(` {refetching ? "Upgrading covers…" : "Re-fetch all album covers"}every record to the canonical high-resolution cover — about one a second `, ` {refetching ? "Upgrading covers…" : "Re-fetch all album covers"}every record to the canonical high-resolution cover — about one a second `); save(); // ── the mocks ── load('src/lib/mockLibrary.js'); rep(`let follows = [];`, `/* the heroes chosen this session — pick_image → set_artist_hero */ const heroes = {}; let follows = [];`); rep(` artist: a, banner: null, banners: [], banner_custom: false, logos: [],`, ` artist: a, banner: heroes[a.id] ?? null, banners: heroes[a.id] ? [heroes[a.id]] : [], banner_custom: !!heroes[a.id], logos: [],`); rep(` if (cmd === "catalog_related") return Promise.resolve(RELATED);`, ` if (cmd === "catalog_related") return Promise.resolve(RELATED); /* Popular's own lane: ranked, with counts — and for the artists whose page gives none (the related ones), the only Popular there is */ if (cmd === "catalog_artist_popular") { const id = Number(args?.artistId); const a = id === CAT_ARTIST.id ? CAT_ARTIST : RELATED.find((r) => r.id === id); if (!a) return Promise.resolve([]); const rows = id === CAT_ARTIST.id ? CAT_TRACKS.slice(0, 7) : TITLES.slice(0, 4).map((t, i) => ({ id: 4200000 + id * 10 + i, title: t, artist: a.name, artist_id: id, rank: 700000 - i * 1000, album: a.name + " Live", album_id: 900 + id, cover: null, duration_secs: 210 + i * 13, preview: null, track_no: i + 1, })); return new Promise((r) => setTimeout(() => r(rows.map((t, i) => ({ ...t, listeners: 1_900_000 - i * 240_000 }))), 260)); } /* the deferred pass: a count per row, by that row's own artist id */ if (cmd === "catalog_track_listeners") { const rows = args?.tracks || []; return new Promise((r) => setTimeout(() => r(rows.map((t, i) => (t.artistId ? 640_000 - i * 20_000 : null))), 260)); } if (cmd === "set_artist_hero") { const id = Number(args?.artistId); heroes[id] = svgArt(180, "H"); return Promise.resolve({ data_url: heroes[id], width: 3840, height: 2160 }); } if (cmd === "clear_artist_hero") { const id = Number(args?.artistId); const had = id in heroes; delete heroes[id]; return Promise.resolve(had); }`); rep(` return Promise.resolve({ album: a, tracks: a.id === 302127 ? CAT_TRACKS.map((t, i) => ({ ...t, listeners: 2_400_000 - i * 150_000 })) : [], album_plays: a.id === 302127 ? 310_000_000 : null, album_listeners: a.id === 302127 ? 4_100_000 : null, totals_summed: false });`, ` /* the last two rows are a guest's: the album's own artist anchor has no count for them, which is what the deferred pass is for */ return Promise.resolve({ album: a, tracks: a.id === 302127 ? CAT_TRACKS.map((t, i) => (i >= CAT_TRACKS.length - 2 ? { ...t, artist: "Daft Punk feat. Todd Edwards", artist_id: 411 } : { ...t, listeners: 2_400_000 - i * 150_000 })) : [], album_plays: a.id === 302127 ? 310_000_000 : null, album_listeners: a.id === 302127 ? 4_100_000 : null, totals_summed: false });`); rep(` if (cmd === "refresh_all_album_art") return new Promise((r) => setTimeout(() => r(140), 300));`, ` if (cmd === "refresh_all_album_art") return new Promise((r) => setTimeout(() => r(140), 300)); if (cmd === "refresh_artist_art") return new Promise((r) => setTimeout(() => r(26), 300));`); /* the thumbnail cache READS A FILE (std::fs::read on the path it is given), so a data: URL comes back as an error there and the gate remembers "". The mock answered every path with a picture, which quietly made the art gate's own data: answer untestable — the hero looked fine either way. */ rep(` if (cmd === "album_art") { const m = /(\\d+)$/.exec(args.path || "");`, ` if (cmd === "album_art") { /* not a path: the real one fails to open it, which is why the art gate has to answer a data: URL itself */ if (String(args.path || "").startsWith("data:")) return Promise.reject(new Error("album_art: not a path")); const m = /(\\d+)$/.exec(args.path || "");`); save(); // ── the walks ── load('tests/mobile/smoke.mjs'); /* every row renders an .lbar whether or not it has a number, so counting the bars proves nothing — count the s that say something */ rep(` cardPhs: document.querySelectorAll('.rail .acard svg').length,`, ` cardPhs: document.querySelectorAll('.rail .acard svg').length, popCounts: [].filter.call(document.querySelectorAll('.row.pop .lbar em'), function (e) { return e.textContent.trim(); }).length, barCounts: [].filter.call(document.querySelectorAll('.trk .lbar em'), function (e) { return e.textContent.trim(); }).length, heroPix: !!document.querySelector('.hero .hero-img.dissolve'),`); rep(` { do: \`__click('.disco-tabs button', 'Singles')\`, name: "cat-artist-tab", wait: 700 },`, ins('steps-hero')); rep(` { do: \`__click('.rail .release', 'Discovery')\`, name: "cat-album", wait: 1500 },`, ` { do: \`__click('.rail .release', 'Discovery')\`, name: "cat-album", wait: 1500 }, /* the two guest rows the record's own anchor had no count for */ { name: "cat-album-listeners", wait: 900 },`); rep(` { do: \`__click('.prefs[data-mode="albumart"] .chip', '1500 px')\`, wait: 400 },`, ins('steps-you')); rep(` "cat-artist-related": (i) => i.title.startsWith("Justice") && i.screen.includes("has-hero"),`, ` "cat-artist-related": (i) => i.title.startsWith("Justice") && i.screen.includes("has-hero") && i.heroPix && i.pop >= 4 && i.popCounts >= 4, "artist-more": (i) => i.half.includes("up") && /Choose hero photo/.test(i.halfText) && !/Remove custom hero/.test(i.halfText), "artist-hero-set": (i) => !i.half.includes("up") && /3840×2160/.test(i.notice), "artist-more-custom": (i) => i.half.includes("up") && /Remove custom hero/.test(i.halfText) && i.heroPix, "artist-hero-cleared": (i) => !i.half.includes("up") && /photos are back/.test(i.notice),`); rep(` "cat-album": (i) => i.screen.includes("tr-push") && i.title.startsWith("Discovery") && i.eds >= 2 && i.lede && i.bars >= 10,`, ` "cat-album": (i) => i.screen.includes("tr-push") && i.title.startsWith("Discovery") && i.eds >= 2 && i.lede && i.bars >= 10, "cat-album-listeners": (i) => i.bars === 14 && i.barCounts === 14,`); rep(` "you-artedge": (i) => i.artEdge === "1000 px",`, ` "you-artedge": (i) => i.artEdge === "1000 px", "you-portraits": (i) => /portraits refreshed/.test(i.notice),`); rep(` { name: "artist", wait: 4500 },`, ins('steps-phone')); rep(` artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img") && i.tabs >= 1,`, ` artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img") && i.tabs >= 1, "artist-more": (i) => i.half.includes("up") && /Choose hero photo/.test(i.halfText),`); save(); // ── the doc ── load('ANDROID.md'); rep(`played track under "On this phone"). Search corrects an obvious`, `played track under "On this phone"). Popular is asked on its own as well as with the page (\`catalog_artist_popular\`: the Last.fm ranking and its counts, waiting on nothing but the linked MBID, so the section can paint before the full page is built — and an artist whose page came back with no Popular at all still gets one), and a catalog record's rows that the album's own artist anchor found no count for — a feature's guest, a compilation's stranger — get the desktop's deferred pass by their OWN Deezer artist id (\`catalog_track_listeners\`). An artist's photo is Kyle's to choose: the artist page's ⋯ offers **Choose hero photo** (the phone's image picker, then \`set_artist_hero\` — \`pick_artist_hero\`'s install step split out and shared, so the desktop's dialog keeps its own door; 1920×800 and landscape or it is refused) and **Remove custom hero**, which puts the catalog's own photos back; the page is asked again either way, so the banner, the ground and the accent all follow. The desktop's own chooser has had no home since its pill left the glass bar — the phone has one first. You → Album artwork carries **Refresh artist portraits** (\`refresh_artist_art\`), the desktop Settings row. A \`data:\` URL is the image itself in \`art.svelte.js\` now — a chosen hero comes back from Rust as one, and the thumbnail fetch used to throw it away. Search corrects an obvious`); save(); console.log('round 25 wired');