jevstrudel.git / worker / src / radio.ts

A signed-in listener's radio history (website/src/jev/JevPicks.jsx), so the radio avoids repeats across sessions and devices:

GET /jev/me/radio { plays: [{ song, at }] }: the newest RECENT plays, newest first POST /jev/me/radio { song }: the radio started this song (one of the deploy's own, its /jev/songs.json)

Both need a session (auth.ts); anonymous radio keeps its history in the tab, as before. Kept in the site's D1 database (radio_plays, migrations/0002_accounts.sql), at most KEPT per user.

12import { d1Accounts, type Accounts } from './accounts-store';
13import { AUTH_LIMIT, signedIn } from './auth';
14import type { Env } from './env';
15import { refuseCrossOrigin } from './session';
16import { knownSongs } from './votes';
18export const RADIO_PATH = '/jev/me/radio';
19export const RECENT = 50;
20export const KEPT = 200;
21const MAX_BYTES = 256;
22
23export type Play = { song: string; at: number };
24
25export function d1Radio(db: D1Database, now: () => number = Date.now) {
26  return {
27    async add(userId: string, song: string): Promise<void> {
28      await db.batch([
29        db.prepare('INSERT INTO radio_plays (user_id, song_id, played_at) VALUES (?, ?, ?)').bind(userId, song, now()),
30        // keep the newest KEPT: the id grows with every insert
31        db
32          .prepare(
33            'DELETE FROM radio_plays WHERE user_id = ? AND id NOT IN (SELECT id FROM radio_plays WHERE user_id = ? ORDER BY id DESC LIMIT ?)',
34          )
35          .bind(userId, userId, KEPT),
36      ]);
37    },
38    async recent(userId: string, limit = RECENT): Promise<Play[]> {
39      const { results } = await db
40        .prepare('SELECT song_id AS song, played_at AS at FROM radio_plays WHERE user_id = ? ORDER BY id DESC LIMIT ?')
41        .bind(userId, limit)
42        .all<Play>();
43      return results;
44    },
45  };
46}
47export type Radio = ReturnType<typeof d1Radio>;
48
49const answer = (status: number, body: unknown, headers: Record<string, string> = {}) =>
50  Response.json(body, { status, headers: { 'Cache-Control': 'no-store', ...headers } });
51
52export async function radio(
53  request: Request,
54  env: Env,
55  accounts: Accounts = d1Accounts(env.DB),
56  store: Radio = d1Radio(env.DB),
57): Promise<Response> {
58  if (request.method !== 'GET' && request.method !== 'POST') {
59    return answer(405, { error: 'GET or POST' }, { Allow: 'GET, POST' });
60  }
61  // index.ts refuses this first; here too, so this route holds alone
62  const crossOrigin = refuseCrossOrigin(request);
63  if (crossOrigin) return crossOrigin;
64  const user = await signedIn(request, accounts);
65  if (!user) return answer(401, { error: 'sign in to keep a radio history' });
66  if (request.method === 'GET') return answer(200, { plays: await store.recent(user.id) });
67
68  const { success } = await env.AUTH_LIMIT.limit({ key: `user:${user.id}` });
69  if (!success) return answer(429, { error: 'too many plays recorded; try again in a minute' }, { 'Retry-After': String(AUTH_LIMIT.period) });
70  const raw = await request.arrayBuffer();
71  if (raw.byteLength > MAX_BYTES) return answer(413, { error: 'request too large' });
72  let song: unknown;
73  try {
74    song = (JSON.parse(new TextDecoder().decode(raw)) as { song?: unknown })?.song;
75  } catch {
76    return answer(400, { error: 'body must be JSON' });
77  }
78  let known: ReadonlySet<string>;
79  try {
80    known = await knownSongs(env);
81  } catch (e) {
82    console.error({ event: 'jev.radio', error: (e as Error).message });
83    return answer(503, { error: 'the radio history is not being kept right now' });
84  }
85  if (typeof song !== 'string' || !known.has(song)) return answer(400, { error: 'song must be one of this site’s songs' });
86  await store.add(user.id, song);
87  return new Response(null, { status: 204, headers: { 'Cache-Control': 'no-store' } });
88}