1// The site's own songs, as the hosted MCP lists and reads them: files the 2// build writes (website/src/pages/jev/catalog.json.js and 3// catalog/[theme]/[song].json.js), read through the ASSETS binding in 4// production and preview, or from the dev server in dev (SONGS_URL's 5// origin), as votes.ts reads the song list. 6import type { Env } from './env'; 7 8export const CATALOG_PATH = '/jev/catalog.json'; 9export const SITE_SONG = /^[a-z0-9-]{1,64}\/[a-z0-9-]{1,64}$/; 10 11export type CatalogEntry = { id: string; title: string; theme: string; description: string; art: number | null; seconds: number | null }; 12export type SiteSong = { id: string; title: string; theme: string; description: string; spec: string; code: string }; 13 14export async function siteFile(env: Env, path: string): Promise<Response> { 15 if (env.SONGS_URL) return fetch(new URL(path, env.SONGS_URL)); 16 if (env.ASSETS) return env.ASSETS.fetch(new Request(new URL(path, 'https://assets.invalid'))); 17 throw new Error('no site files: neither SONGS_URL nor ASSETS is bound'); 18} 19 20export async function catalog(env: Env): Promise<CatalogEntry[]> { 21 const res = await siteFile(env, CATALOG_PATH); 22 if (!res.ok) throw new Error(`the site's song list answered ${res.status}`); 23 const { songs } = (await res.json()) as { songs?: CatalogEntry[] }; 24 if (!Array.isArray(songs)) throw new Error("the site's song list is malformed"); 25 return songs; 26} 27 28// One site song whole, or null when the site has none by that id. 29export async function siteSong(env: Env, id: string): Promise<SiteSong | null> { 30 if (!SITE_SONG.test(id)) return null; 31 const res = await siteFile(env, `/jev/catalog/${id}.json`); 32 if (res.status === 404) return null; 33 if (!res.ok) throw new Error(`the site's song ${id} answered ${res.status}`); 34 const song = (await res.json()) as SiteSong; 35 return typeof song?.code === 'string' ? song : null; 36}