1// A signed-in listener's radio history (website/src/jev/JevPicks.jsx), so 2// the radio avoids repeats across sessions and devices: 3// 4// GET /jev/me/radio { plays: [{ song, at }] }: the newest RECENT plays, 5// newest first 6// POST /jev/me/radio { song }: the radio started this song (one of the 7// deploy's own, its /jev/songs.json) 8// 9// Both need a session (auth.ts); anonymous radio keeps its history in the 10// tab, as before. Kept in the site's D1 database (radio_plays, 11// 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'; 17 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}