Merge branch 'feat/listening-week': spec 004, the week's top track from ListenBrainz
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
1131dbdfcd
14 files changed
+303
-15
No files matched your search
@@ -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` |
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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'));
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
Reference in new issue
Block a user