Merge branch 'feat/listening-week': spec 004, the week's top track from ListenBrainz
agent-docs / check (push) Successful in 4s
ci / test (push) Successful in 1m45s
ci / image (push) Successful in 11s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-11-10 13:45:16 +01:00
commit 1131dbdfcd
14 files changed
+303 -15

No files matched your search

+1 -1
View File
@@ -14,7 +14,7 @@ content format: `docs/CONTENT-CONTRACT.md`; deployment: `docs/DEPLOY.md`.
|---|---|
| `src/core/` | Pure, dependency-free logic shared by the build and the browser: AI levels and visibility (`ai.ts`), durations, issue numbering (`issues.ts`), `lazyImages` for Markdown bodies (`post-html.ts`), media extensions (`media.ts`), showcase ids (`showcases.ts`) |
| `src/library/` | Reads and validates the content repo (`load.ts`, `frontmatter.ts`, `types.ts`, `errors.ts`); `RESERVED_PERSONAL_IDS` guards ids that would shadow a page |
| `src/sources/` | Ghost, Trakt and Hardcover clients plus a JSON cache (`cache.ts`); `loadSources` honours `SOURCES_FIXTURES`. Trakt: last film (`history/movies?limit=1` + `ratings/movies`) and recent ratings; no "watching now" |
| `src/sources/` | Ghost, Trakt and Hardcover clients plus a JSON cache (`cache.ts`); `loadSources` honours `SOURCES_FIXTURES`. Trakt: last film (`history/movies?limit=1` + `ratings/movies`) and recent ratings; no "watching now". ListenBrainz (`LISTENBRAINZ_USER`, no token): the week's top track, counted from the listens |
| `src/site/` | The Astro site: `pages/` (gate, `work/`, `off-the-clock/` with `issues/`, `everything`, `simple`, 404), `components/` (Astro parts, React islands, `showcases/`), `layouts/` (`Base`, `Work`, `Personal`), `styles/` (per-space CSS and tokens), `scripts/` (`preference.ts`), `data.ts` (`getSiteData`: content plus cached sources, never fetches), `post-html.ts` (`safePostHtml`, Ghost HTML allowlist on Bun's `HTMLRewriter`; build-time only) |
| `src/runtime/` | `build.ts` (validate, fetch sources once, run Astro, copy media), `refresh.ts` (poll git, timed rebuild, swap `live`), `serve.ts`, `static-server.ts` (`/status`, 404 page), `state.ts`, `git.ts` |
| `src/cli.ts` | Entry point: `validate`, `build`, `preview`, `refresh`, `serve` |
+11
View File
@@ -304,3 +304,14 @@ from the top, capped at 700 px on the item page.
14 px margins; song titles and the play note break in balanced lines.
**Consequences:** No record animation anywhere. Bringing it back means re-adding `.rec` and the reserved gap.
## 2026-11-10 — Listening: the week's top track from ListenBrainz, counted by the site
**Status:** accepted
**Context:** Spec 004. The owner's listening is on ListenBrainz (mirrored from Last.fm). ListenBrainz's weekly stats
lagged by weeks; the listens were current.
**Decision:** The site fetches the listens (1000 per page, newest first, at most 5 pages, paging with `max_ts` until
past 7 days) and counts the top track itself, grouped case-insensitively, ties to the most recent. It replaces the
pinned-song cell "for now". The source only runs when `LISTENBRAINZ_USER` is set: an unkeyed default made the test
build call the live service.
**Alternatives:** ListenBrainz's stats endpoint (stale); Last.fm (needs an API key, same data).
**Consequences:** One to five requests per build. The production environment needs `LISTENBRAINZ_USER`.
+1
View File
@@ -43,6 +43,7 @@ both stacks to `site.yml` and the payload table in `server/README.md` like the o
| `GHOST_CONTENT_KEY` | secret `ghost_content_key` |
| `TRAKT_CLIENT_ID` / `TRAKT_USER` | secret `trakt_client_id` / `lucasw89` |
| `HARDCOVER_TOKEN` | secret `hardcover_token` |
| `LISTENBRAINZ_USER` | `stereotypicalcat` (not a secret; without it the Listening cell is left out) |
| `VIRTUAL_HOST`, `LETSENCRYPT_HOST` | `lucaswinther.info` |
`SOURCES_FIXTURES=<dir>` (used by the smoke test and `compose.local.yml`) reads the fixture JSON in
+3
View File
@@ -4,6 +4,9 @@ Where development stopped, and how to pick it up. Delete or rewrite this file wh
## State
- **Spec 004 (Listening: the week's top track from ListenBrainz)** is built on `feat/listening-week`, stacked on
`feat/last-film-and-wall-variety`; neither is merged. Production needs `LISTENBRAINZ_USER=stereotypicalcat`
(`docs/DEPLOY.md`). The live fetch was seen working once (by accident, in a test build; now prevented).
- **Spec 003 (last film from Trakt in the "right now" strip, film posters alternating sides, band photo 4:3) is
built** on `feat/last-film-and-wall-variety`, not merged. `bun test` 176 pass, `bunx playwright test` 129 pass /
9 skipped. Content branch `chore/rachie-spotify` (Summer Blooms Without You plays from Spotify) is not pushed.
+2
View File
@@ -20,6 +20,8 @@ Small, specific, independently actionable. Remove only when actually done.
- [ ] Reword the 2021 `portfolio` project summary if wanted ("The 2021 version of this portfolio…", derived from its `info.json`).
## Next
- [ ] Homelab config: add `LISTENBRAINZ_USER=stereotypicalcat` to the site's environment (spec 004), or the strip's Listening cell stays out.
- [ ] The strip's Listening cell replaced the newest pinned song (owner: "for now, then we can look at alternatives later"). The pinned song's note ("why ↘") now has no spot in the strip; look at alternatives.
- [ ] Revisit the start page's "or wander through everything at once" pill (and its G shortcut). Hidden for now on the owner's request: `SHOW_EVERYTHING_PILL` in `src/site/pages/index.astro`. Its CSS, the seam positioning in `Gate.tsx` and the seam check in `e2e/gate.spec.ts` are kept; `/everything/` is still reachable from `/simple`.
- [ ] Personal wall: a chip filter is not reflected in the URL, so a filtered view can't be shared.
- [ ] Film soundtrack, alternative design: mockup B (the soundtrack as a record tucked behind the poster, slides out on hover; `mockups/shots/spec-002-r1-cards.png`). The owner kept it as an idea for later or as an alternative to the tray.
+45
View File
@@ -0,0 +1,45 @@
---
status: implemented
---
# 004 — Listening: the week's top track from ListenBrainz
Designed with the owner in conversation, 2026-11-10. Extends `docs/specs/001-site.md` and `docs/specs/003-last-film-and-wall-variety.md`.
Labels: **[D]** decided by the owner. **[C]** builder's call.
## 1. Intent
The "right now" strip's **Listening** cell shows the newest song pinned on the wall. The owner's actual listening is on
ListenBrainz (`stereotypicalcat`: 10,782 listens, mirrored from Last.fm by ListenBrainz's importer, current to the
day). The cell shows **the top track of the latest week** instead. [D] It replaces the pinned song "for now";
alternatives for showing the pinned song come later. [D]
**Non-goals:** Last.fm directly (ListenBrainz has the same listens and needs no key); album art (third-party images
stay out before interaction, so the cover is a typeset stand-in, like the book and film cells) [C]; a listening card on
the wall.
## 2. Data
- ListenBrainz's own weekly stats lag (on 2026-11-10 they were last computed 2026-10-24), so the site counts the
week itself from the listens. [D: "sounds good on the stats"]
- `GET https://api.listenbrainz.org/1/user/<user>/listens?count=1000`, newest first, paging back with `max_ts` (the
oldest `listened_at` seen) until a listen is older than 7 days before the build or a page has fewer than 1000 listens.
At most 5 pages. No token. [C]
- The top track is the one with the most listens in the last 7 days, grouped by artist + title (case-insensitive,
trimmed); a tie goes to the one played most recently. Shown with its listen count. [C]
- `Sources['listening'] = { top: TopTrack | null }`, `TopTrack = { artist: string; title: string; plays: number; url: string }`;
`url` is the owner's ListenBrainz page. No listens in the window → `top: null` and the cell is left out.
- `LISTENBRAINZ_USER` (default `stereotypicalcat`). A new source `listenbrainz` with the same cache-and-status
behaviour as the others (shown on `/status`). Fixtures: `fixtures/sources/listenbrainz-listens.json`; in fixture
mode the window ends at the newest fixture listen, so builds are deterministic. [C]
## 3. The cell
Same position and shape as today's Listening cell: heading **"Listening"** with **"ListenBrainz ↗"**; the typeset stand-in
cover (swatch by artist + title); the title in bold; "<artist>" small; "<n> plays this week" small; the "synced … ago" line.
## 4. Acceptance criteria
- Unit: top track counted over the window; case/whitespace variants count as one; ties go to the most recent; listens
older than 7 days ignored; paging stops at the window edge (a test where page 2 is needed and page 3 is never asked);
empty → `null`; non-2xx → source error with cache fallback.
- Build test: the strip shows "Listening", the fixture top track, "<n> plays this week", "ListenBrainz ↗"; no
"on the wall since".
- `bun run typecheck`, `bun test`, `bunx playwright test` pass; screenshot of the strip.
- Docs: ARCHITECTURE (sources), DEPLOY (env), DECISIONS, TODO (pinned-song alternatives).
@@ -0,0 +1,80 @@
{
"payload": {
"count": 9,
"user_id": "fixture-user",
"listens": [
{
"listened_at": 1751371200,
"track_metadata": {
"artist_name": "Fixture Band",
"track_name": "Synthetic Track A",
"additional_info": {}
}
},
{
"listened_at": 1751367600,
"track_metadata": {
"artist_name": "Other Fixture",
"track_name": "Synthetic Track B",
"additional_info": {}
}
},
{
"listened_at": 1751284800,
"track_metadata": {
"artist_name": "fixture band ",
"track_name": "synthetic track a",
"additional_info": {}
}
},
{
"listened_at": 1751198400,
"track_metadata": {
"artist_name": "Other Fixture",
"track_name": "Synthetic Track B",
"additional_info": {}
}
},
{
"listened_at": 1751112000,
"track_metadata": {
"artist_name": "Fixture Band",
"track_name": "Synthetic Track A",
"additional_info": {}
}
},
{
"listened_at": 1750680000,
"track_metadata": {
"artist_name": "Old Fixture",
"track_name": "Old Track",
"additional_info": {}
}
},
{
"listened_at": 1750679940,
"track_metadata": {
"artist_name": "Old Fixture",
"track_name": "Old Track",
"additional_info": {}
}
},
{
"listened_at": 1750679880,
"track_metadata": {
"artist_name": "Old Fixture",
"track_name": "Old Track",
"additional_info": {}
}
},
{
"listened_at": 1750679820,
"track_metadata": {
"artist_name": "Old Fixture",
"track_name": "Old Track",
"additional_info": {}
}
}
]
}
}
+13 -7
View File
@@ -1,5 +1,5 @@
---
// "Right now": listening (newest song on the wall), reading (Hardcover), last film (Trakt history, with the
// "Right now": listening (the week's top track on ListenBrainz), reading (Hardcover), last film (Trakt history, with the
// owner's rating), playing
// (newest game on the wall). A cell without data is left out; with none, the strip is not rendered.
import Cover from './cards/Cover.astro';
@@ -7,7 +7,8 @@ import { agoText, longDate, shortDate, stars, swatch, type Entry } from './entri
import type { Sources } from '../../../sources/types.ts';
interface Props { entries: Entry[]; sources: Sources; now: number }
const { entries, sources, now } = Astro.props;
const song = entries.find((e) => e.kind === 'song');
const top = sources.listening.top;
const lbSynced = agoText(sources.status.listenbrainz.fetchedAt, now);
const game = entries.find((e) => e.kind === 'game');
const book = sources.hardcover.reading[0];
const film = sources.trakt.lastFilm;
@@ -17,17 +18,22 @@ const trSynced = agoText(sources.status.trakt.fetchedAt, now);
const pct = (p: number | undefined) => (p === undefined ? undefined : Math.min(100, Math.max(0, Math.round(p))));
const filmStars = film?.rating !== undefined ? stars(film.rating, true) : null;
const filmDate = film ? (film.date.slice(0, 4) === new Date(now).toISOString().slice(0, 4) ? shortDate(film.date) : longDate(film.date)) : '';
const cells = [song, book, film, game].filter(Boolean).length;
const cells = [top, book, film, game].filter(Boolean).length;
const href = (e: Entry) => `/off-the-clock/${e.id}/`;
---
{cells > 0 && (
<div class="now" aria-label="Right now" style={`--n:${cells}`}>
{song && (
{top && (
<div>
<h2><span>Listening</span><a href={href(song)} data-open={song.id}>why ↘</a></h2>
<h2><span>Listening</span><a href={top.url} rel="noopener">ListenBrainz ↗</a></h2>
<div class="x">
<a class="c" href={href(song)} data-open={song.id} tabindex="-1"><Cover entry={song} titled={false} /></a>
<div><b>{song.title}</b><small>on the wall since {shortDate(song.date)}</small></div>
<span class="c tall typeset" style={`background:${swatch(`${top.artist} ${top.title}`).bg}`} aria-hidden="true">{top.title}</span>
<div>
<b>{top.title}</b>
<small>{top.artist}</small>
<small>{top.plays} {top.plays === 1 ? 'play' : 'plays'} this week</small>
{lbSynced && <small><time data-ago="synced" datetime={sources.status.listenbrainz.fetchedAt}>{lbSynced}</time></small>}
</div>
</div>
</div>
)}
+3 -1
View File
@@ -44,7 +44,8 @@ export function emptySources(): Sources {
posts: [],
trakt: { lastFilm: null, recent: [] },
hardcover: { reading: [], recent: [] },
status: { ghost: { ...NOT_FETCHED }, trakt: { ...NOT_FETCHED }, hardcover: { ...NOT_FETCHED } },
listening: { top: null },
status: { ghost: { ...NOT_FETCHED }, trakt: { ...NOT_FETCHED }, hardcover: { ...NOT_FETCHED }, listenbrainz: { ...NOT_FETCHED } },
};
}
@@ -56,6 +57,7 @@ async function readSources(cacheDir: string): Promise<Sources> {
posts: raw.posts ?? empty.posts,
trakt: raw.trakt ?? empty.trakt,
hardcover: raw.hardcover ?? empty.hardcover,
listening: raw.listening ?? empty.listening,
status: { ...empty.status, ...raw.status },
};
} catch {
+18 -3
View File
@@ -3,15 +3,17 @@ import { runSource } from './cache.ts';
import { fetchGhost, parseGhost } from './ghost.ts';
import { fetchHardcover, parseHardcover } from './hardcover.ts';
import { fetchTrakt, parseLastFilm, parseRatings } from './trakt.ts';
import { fetchListenBrainz, topTrack, type LbListen } from './listenbrainz.ts';
import type { Sources } from './types.ts';
export type { LastFilm, Post, Rated, Reading, SourceName, SourceStatus, Sources } from './types.ts';
export type { LastFilm, Post, Rated, Reading, SourceName, SourceStatus, Sources, TopTrack } from './types.ts';
export { postFromGhost } from './ghost.ts';
export { HARDCOVER_QUERY } from './hardcover.ts';
export async function loadSources(opts: {
cacheDir: string; env: Record<string, string | undefined>; fetch?: typeof fetch;
timeoutMs?: number; // per request; a stalled host must fall back to the cache, not hang the build
now?: number; // ms; the end of the listening window (tests pin it)
}): Promise<Sources> {
const { cacheDir, env } = opts;
const base = opts.fetch ?? fetch;
@@ -22,7 +24,8 @@ export async function loadSources(opts: {
const fixture = (name: string) => Bun.file(join(fixtures!, name)).json() as Promise<unknown>;
const common = { cacheDir, persist: !fixtures };
const [ghost, trakt, hardcover] = await Promise.all([
const now = opts.now ?? Date.now();
const [ghost, trakt, hardcover, listening] = await Promise.all([
runSource({
...common, name: 'ghost', empty: [],
fetchFresh: fixtures ? async () => parseGhost(await fixture('ghost-posts.json')) : () => fetchGhost(env, f),
@@ -43,6 +46,17 @@ export async function loadSources(opts: {
...common, name: 'hardcover', empty: { reading: [], recent: [] } as Sources['hardcover'],
fetchFresh: fixtures ? async () => parseHardcover(await fixture('hardcover.json')) : () => fetchHardcover(env, f),
}),
runSource({
...common, name: 'listenbrainz', empty: { top: null } as Sources['listening'],
fetchFresh: fixtures
? async () => {
// Fixture mode: the window ends at the newest fixture listen, so builds don't drift with the clock.
const listens = ((await fixture('listenbrainz-listens.json')) as { payload: { listens: LbListen[] } }).payload.listens;
const newest = Math.max(...listens.map((l) => l.listened_at));
return { top: topTrack(listens, newest - 7 * 86400, 'fixture-user') };
}
: () => fetchListenBrainz(env, f, now),
}),
]);
return {
@@ -50,6 +64,7 @@ export async function loadSources(opts: {
// A cache written before spec 003 has `watching` and no `lastFilm`: read it as no last film.
trakt: { lastFilm: trakt.data.lastFilm ?? null, recent: trakt.data.recent ?? [] },
hardcover: hardcover.data,
status: { ghost: ghost.status, trakt: trakt.status, hardcover: hardcover.status },
listening: { top: listening.data?.top ?? null },
status: { ghost: ghost.status, trakt: trakt.status, hardcover: hardcover.status, listenbrainz: listening.status },
};
}
+48
View File
@@ -0,0 +1,48 @@
// ListenBrainz: the owner's top track of the last 7 days, counted from the listens themselves
// (ListenBrainz's own weekly stats lag by weeks). Public data, no token.
import type { TopTrack } from './types.ts';
export type LbListen = { listened_at: number; track_metadata?: { artist_name?: string; track_name?: string } };
const WEEK = 7 * 86400;
const PAGE = 1000;
const MAX_PAGES = 5;
const key = (s: string) => s.trim().toLowerCase();
// Listens are newest first. Ties go to the track played most recently (it was seen first).
export function topTrack(listens: LbListen[], since: number, user: string): TopTrack | null {
const tally = new Map<string, { artist: string; title: string; plays: number }>();
for (const l of listens) {
const artist = l.track_metadata?.artist_name?.trim();
const title = l.track_metadata?.track_name?.trim();
if (!artist || !title || !(l.listened_at >= since)) continue;
const k = `${key(artist)}\u0000${key(title)}`;
const t = tally.get(k);
if (t) t.plays++;
else tally.set(k, { artist, title, plays: 1 });
}
let best: { artist: string; title: string; plays: number } | null = null;
for (const t of tally.values()) if (!best || t.plays > best.plays) best = t;
return best && { ...best, url: `https://listenbrainz.org/user/${encodeURIComponent(user)}/` };
}
export async function fetchListenBrainz(env: Record<string, string | undefined>, f: typeof fetch, now: number): Promise<{ top: TopTrack | null }> {
const user = env.LISTENBRAINZ_USER;
if (!user) throw new Error('LISTENBRAINZ_USER is not set'); // opt-in, so unconfigured builds and tests stay offline
const since = Math.floor(now / 1000) - WEEK;
const base = `https://api.listenbrainz.org/1/user/${encodeURIComponent(user)}/listens?count=${PAGE}`;
const listens: LbListen[] = [];
let maxTs: number | undefined;
for (let page = 0; page < MAX_PAGES; page++) {
const r = await f(maxTs === undefined ? base : `${base}&max_ts=${maxTs}`);
if (!r.ok) throw new Error(`ListenBrainz responded ${r.status}`);
const body = (await r.json()) as { payload?: { listens?: LbListen[] } };
const batch = body?.payload?.listens;
if (!Array.isArray(batch)) throw new Error('ListenBrainz listens response has no list');
listens.push(...batch);
const oldest = batch.at(-1)?.listened_at;
if (batch.length < PAGE || oldest === undefined || oldest < since) break;
maxTs = oldest;
}
return { top: topTrack(listens, since, user) };
}
+4 -1
View File
@@ -9,11 +9,14 @@ export type Post = {
export type LastFilm = { title: string; year?: number; url: string; date: string; rating?: number } | null;
export type Rated = { title: string; rating: number; url: string; date: string };
export type Reading = { title: string; author: string; progress?: number; url: string; cover?: string };
export type SourceName = 'ghost' | 'trakt' | 'hardcover';
// The owner's most played track of the last 7 days (ListenBrainz); url is his ListenBrainz page.
export type TopTrack = { artist: string; title: string; plays: number; url: string };
export type SourceName = 'ghost' | 'trakt' | 'hardcover' | 'listenbrainz';
export type SourceStatus = { ok: boolean; fetchedAt?: string; error?: string };
export type Sources = {
posts: Post[];
trakt: { lastFilm: LastFilm; recent: Rated[] };
hardcover: { reading: Reading[]; recent: Rated[] };
listening: { top: TopTrack | null };
status: Record<SourceName, SourceStatus>;
};
+11
View File
@@ -278,6 +278,17 @@ describe('personal space', () => {
expect(page(out, 'off-the-clock/demo-film/index.html')).not.toContain('film-grid flip');
});
test('the now strip shows the week\'s top track from ListenBrainz', () => {
const html = page(out, 'off-the-clock/index.html');
const strip = html.slice(html.indexOf('<h2><span>Listening</span>'), html.indexOf('<h2><span>Reading</span>'));
expect(strip).toContain('href="https://listenbrainz.org/user/fixture-user/"');
expect(strip).toContain('ListenBrainz ↗');
expect(strip).toContain('<b>Synthetic Track A</b>');
expect(strip).toContain('Fixture Band');
expect(strip).toContain('3 plays this week');
expect(html).not.toContain('on the wall since');
});
test('shares carry no data-ai', async () => {
const { content } = await loadContent('fixtures/content');
const cards = cardTags(page(out, 'off-the-clock/index.html'));
+63 -2
View File
@@ -9,14 +9,14 @@ const read = (n: string) => Bun.file(join(FIX, n)).text();
const env = {
GHOST_URL: 'https://blog.example.test', GHOST_CONTENT_KEY: 'k',
TRAKT_CLIENT_ID: 'cid', TRAKT_USER: 'lucasw89', HARDCOVER_TOKEN: 'tok',
TRAKT_CLIENT_ID: 'cid', TRAKT_USER: 'lucasw89', HARDCOVER_TOKEN: 'tok', LISTENBRAINZ_USER: 'fixture-user',
};
let dir: string;
beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'sources-')); });
afterEach(async () => { await rm(dir, { recursive: true, force: true }); });
type Opts = { traktStatus?: number; history?: string; movieRatings?: string; ratingPages?: string[]; seen?: string[] };
type Opts = { traktStatus?: number; history?: string; movieRatings?: string; ratingPages?: string[]; seen?: string[]; lbPages?: unknown[][]; lbStatus?: number };
function fixtureFetch(o: Opts = {}): typeof fetch {
return (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
@@ -36,6 +36,13 @@ function fixtureFetch(o: Opts = {}): typeof fetch {
}
if (url.endsWith('/ratings?limit=5')) return json(await read('trakt-ratings.json'));
}
if (url.startsWith('https://api.listenbrainz.org/1/user/fixture-user/listens?count=1000')) {
if (o.lbStatus) return json('', o.lbStatus);
const pages = o.lbPages ?? [JSON.parse(await read('listenbrainz-listens.json')).payload.listens];
const maxTs = url.match(/&max_ts=(\d+)/)?.[1];
const i = maxTs ? (o.lbPages ?? []).findIndex((p) => (p as { listened_at: number }[]).every((l) => l.listened_at < Number(maxTs))) : 0;
return json(JSON.stringify({ payload: { listens: i < 0 ? [] : pages[i] } }));
}
if (url === 'https://api.hardcover.app/v1/graphql') {
if (new Headers(init?.headers).get('authorization') !== 'Bearer tok') return json('', 401);
return json(await read('hardcover.json'));
@@ -198,3 +205,57 @@ test('a stalled request times out and falls back to the cache', async () => {
expect(second.trakt).toEqual(first.trakt);
expect(second.hardcover).toEqual(first.hardcover);
});
/* ---------- ListenBrainz: the week's top track (spec 004) ---------- */
const NOW = (1751371200 + 3600) * 1000; // an hour after the newest fixture listen
const lb = (ts: number, artist: string, track: string) => ({ listened_at: ts, track_metadata: { artist_name: artist, track_name: track } });
test('listening: the most played track of the last 7 days, variants counted as one', async () => {
const s = await loadSources({ cacheDir: dir, env, fetch: fixtureFetch(), now: NOW });
expect(s.status.listenbrainz.ok).toBe(true);
expect(s.listening.top).toEqual({ artist: 'Fixture Band', title: 'Synthetic Track A', plays: 3, url: 'https://listenbrainz.org/user/fixture-user/' });
});
test('listening: a tie goes to the track played most recently', async () => {
const t = 1751371200;
const page = [lb(t, 'B', 'Two'), lb(t - 10, 'A', 'One'), lb(t - 20, 'B', 'Two'), lb(t - 30, 'A', 'One')];
const s = await loadSources({ cacheDir: dir, env, fetch: fixtureFetch({ lbPages: [page] }), now: NOW });
expect(s.listening.top?.title).toBe('Two');
});
test('listening: pages back until the window edge, and no further', async () => {
const t = 1751371200;
const page1 = Array.from({ length: 1000 }, (_, i) => lb(t - i, 'A', 'One'));
const page2 = [lb(t - 2000, 'B', 'Two'), lb(t - 9 * 86400, 'C', 'Old')];
const page3 = [lb(t - 10 * 86400, 'C', 'Old')];
const seen: string[] = [];
const s = await loadSources({ cacheDir: dir, env, fetch: fixtureFetch({ lbPages: [page1, page2, page3], seen }), now: NOW });
expect(s.listening.top).toMatchObject({ title: 'One', plays: 1000 });
expect(seen.filter((u) => u.includes('listenbrainz')).length).toBe(2);
});
test('listening: nothing in the last 7 days means no top track', async () => {
const s = await loadSources({ cacheDir: dir, env, fetch: fixtureFetch({ lbPages: [[lb(1751371200 - 8 * 86400, 'C', 'Old')]] }), now: NOW });
expect(s.listening.top).toBeNull();
});
test('listening: a failing ListenBrainz falls back like the other sources', async () => {
const s = await loadSources({ cacheDir: dir, env, fetch: fixtureFetch({ lbStatus: 503 }), now: NOW });
expect(s.status.listenbrainz.ok).toBe(false);
expect(s.status.listenbrainz.error).toContain('503');
expect(s.listening).toEqual({ top: null });
});
test('listening: a cache from before ListenBrainz reads as no top track', async () => {
const down = (async () => { throw new Error('network down'); }) as unknown as typeof fetch;
const s = await loadSources({ cacheDir: dir, env, fetch: down });
expect(s.listening).toEqual({ top: null });
});
test('listening: without LISTENBRAINZ_USER nothing is fetched', async () => {
const seen: string[] = [];
const s = await loadSources({ cacheDir: dir, env: {}, fetch: fixtureFetch({ seen }) });
expect(seen.some((u) => u.includes('listenbrainz'))).toBe(false);
expect(s.status.listenbrainz.error).toContain('LISTENBRAINZ_USER is not set');
expect(s.listening).toEqual({ top: null });
});