An API for my personal portfolio
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

move to cf

+245 -229
+1
.gitignore
··· 2 2 .direnv/ 3 3 .env* 4 4 bun.lock 5 + .wrangler/
-10
Dockerfile
··· 1 - FROM oven/bun:1 AS base 2 - WORKDIR /app 3 - 4 - COPY package.json bun.lock ./ 5 - RUN bun install --frozen-lockfile --production 6 - 7 - COPY src/ src/ 8 - 9 - EXPOSE 3000 10 - CMD ["bun", "run", "src/index.ts"]
+39 -15
README.md
··· 1 1 # api.matthew-hre.com 2 2 3 - Personal API powering [matthew-hre.com](https://matthew-hre.com). Built with [Hono](https://hono.dev) on [Bun](https://bun.sh), deployed via [Dokploy](https://dokploy.com). 3 + Personal API powering [matthew-hre.com](https://matthew-hre.com). Built with 4 + [Hono](https://hono.dev) on [Cloudflare Workers](https://workers.cloudflare.com), 5 + backed by [D1](https://developers.cloudflare.com/d1/), and managed with 6 + [Wrangler](https://developers.cloudflare.com/workers/wrangler/). 4 7 5 8 ## Endpoints 6 9 7 10 ### `GET /vinyl` 8 11 9 - Paginated vinyl collection, synced nightly from Discogs. 12 + Paginated vinyl collection, synced from Discogs every 6 hours via cron. 10 13 11 14 | Param | Default | Options | 12 15 | ------- | --------- | ---------------------------- | ··· 16 19 17 20 ### `GET /activity/music` 18 21 19 - Currently playing (or last played) track from Last.fm. 22 + Currently playing (or last played) track from Last.fm. Polled into D1 every 23 + minute by a cron trigger. 20 24 21 25 ### `GET /activity/music/stream` 22 26 23 - SSE stream that pushes track changes in real-time. Polls Last.fm every ~10 seconds server-side and broadcasts to all connected clients. 27 + SSE stream that pushes track changes. Reads from the D1-cached value every 28 + 10s and emits when it changes. 24 29 25 30 ## Local Development 26 31 27 32 ```bash 28 - cp .env.example .env # fill in your tokens 29 - docker compose up -d # start Postgres 33 + nix develop # gets you bun + wrangler 34 + 30 35 bun install 31 - bun run sync # seed the database from Discogs 32 - bun run dev # start the API on :3000 36 + wrangler d1 create api # one-time; copy the id into wrangler.toml 37 + bun run migrate:local # applies migrations/ to local D1 38 + bun run dev # starts the worker on :8787 33 39 ``` 34 40 35 - ## Environment Variables 41 + Trigger the cron handlers locally with: 36 42 37 - | Variable | Description | 38 - | -------------------------------- | ---------------------------- | 39 - | `DATABASE_URL` | Postgres connection string | 40 - | `DISCOGS_PERSONAL_ACCESS_TOKEN` | Discogs API token | 41 - | `LASTFM_API_KEY` | Last.fm API key | 42 - | `LASTFM_USERNAME` | Last.fm username | 43 + ```bash 44 + # Last.fm poll 45 + curl 'http://localhost:8787/__scheduled?cron=*+*+*+*+*' 46 + # Discogs sync 47 + curl 'http://localhost:8787/__scheduled?cron=0+*/6+*+*+*' 48 + ``` 49 + 50 + ## Deployment 51 + 52 + ```bash 53 + bun run migrate:remote # apply migrations to remote D1 54 + bun run deploy # wrangler deploy 55 + ``` 56 + 57 + ## Secrets 58 + 59 + Set with `wrangler secret put <NAME>`: 60 + 61 + | Variable | Description | 62 + | -------------------------------- | --------------------- | 63 + | `DISCOGS_PERSONAL_ACCESS_TOKEN` | Discogs API token | 64 + | `LASTFM_API_KEY` | Last.fm API key | 65 + 66 + `LASTFM_USERNAME` and `DISCOGS_USER` are plain `[vars]` in `wrangler.toml`.
-29
bun.lock
··· 1 - { 2 - "lockfileVersion": 1, 3 - "configVersion": 1, 4 - "workspaces": { 5 - "": { 6 - "name": "api.matthew-hre.com", 7 - "dependencies": { 8 - "hono": "^4.7.10", 9 - "postgres": "^3.4.7", 10 - }, 11 - "devDependencies": { 12 - "@types/bun": "^1.2.14", 13 - }, 14 - }, 15 - }, 16 - "packages": { 17 - "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], 18 - 19 - "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], 20 - 21 - "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], 22 - 23 - "hono": ["hono@4.12.14", "", {}, "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w=="], 24 - 25 - "postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="], 26 - 27 - "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], 28 - } 29 - }
-14
docker-compose.yml
··· 1 - services: 2 - db: 3 - image: postgres:16 4 - ports: 5 - - "5432:5432" 6 - environment: 7 - POSTGRES_USER: postgres 8 - POSTGRES_PASSWORD: postgres 9 - POSTGRES_DB: api 10 - volumes: 11 - - pgdata:/var/lib/postgresql/data 12 - 13 - volumes: 14 - pgdata:
+21
migrations/0001_init.sql
··· 1 + -- Vinyl collection synced from Discogs 2 + CREATE TABLE IF NOT EXISTS releases ( 3 + discogs_id INTEGER PRIMARY KEY, 4 + title TEXT NOT NULL, 5 + artist_name TEXT NOT NULL, 6 + cover_image TEXT NOT NULL, 7 + date_added TEXT NOT NULL 8 + ); 9 + 10 + CREATE INDEX IF NOT EXISTS releases_date_added_idx ON releases (date_added); 11 + CREATE INDEX IF NOT EXISTS releases_title_idx ON releases (title); 12 + CREATE INDEX IF NOT EXISTS releases_artist_idx ON releases (artist_name); 13 + 14 + -- Single-row cache of the currently-playing Last.fm track 15 + CREATE TABLE IF NOT EXISTS now_playing ( 16 + id INTEGER PRIMARY KEY CHECK (id = 1), 17 + payload TEXT, -- JSON-encoded Track or NULL 18 + updated_at INTEGER NOT NULL -- epoch ms 19 + ); 20 + 21 + INSERT OR IGNORE INTO now_playing (id, payload, updated_at) VALUES (1, NULL, 0);
+2
nix/devShell.nix
··· 2 2 mkShell, 3 3 alejandra, 4 4 bun, 5 + wrangler, 5 6 }: 6 7 mkShell { 7 8 name = "api.matthew-hre.com"; 8 9 9 10 packages = [ 10 11 bun 12 + wrangler 11 13 12 14 alejandra 13 15 ];
+10 -10
package.json
··· 3 3 "version": "0.1.0", 4 4 "private": true, 5 5 "scripts": { 6 - "dev": "bun run --watch src/index.ts", 7 - "start": "bun run src/index.ts", 8 - "sync": "bun run src/sync.ts" 9 - }, 10 - "engines": { 11 - "node": ">=22.0.0", 12 - "bun": ">=1.3.0" 6 + "dev": "wrangler dev", 7 + "deploy": "wrangler deploy", 8 + "migrate:local": "wrangler d1 migrations apply api --local", 9 + "migrate:remote": "wrangler d1 migrations apply api --remote", 10 + "sync:local": "wrangler dev --test-scheduled & sleep 2 && curl 'http://localhost:8787/__scheduled?cron=0+*/6+*+*+*'", 11 + "typecheck": "tsc --noEmit" 13 12 }, 14 13 "dependencies": { 15 - "hono": "^4.7.10", 16 - "postgres": "^3.4.7" 14 + "hono": "^4.7.10" 17 15 }, 18 16 "devDependencies": { 19 - "@types/bun": "^1.2.14" 17 + "@cloudflare/workers-types": "^4.20250101.0", 18 + "typescript": "^5.6.0", 19 + "wrangler": "^3.95.0" 20 20 } 21 21 }
-7
src/db.ts
··· 1 - import postgres from "postgres"; 2 - 3 - const sql = postgres(process.env.DATABASE_URL!, { 4 - max: 10, 5 - }); 6 - 7 - export { sql };
+11
src/env.ts
··· 1 + export type Env = { 2 + DB: D1Database; 3 + LASTFM_API_KEY: string; 4 + LASTFM_USERNAME: string; 5 + DISCOGS_PERSONAL_ACCESS_TOKEN: string; 6 + DISCOGS_USER: string; 7 + }; 8 + 9 + export type AppContext = { 10 + Bindings: Env; 11 + };
+17 -2
src/index.ts
··· 2 2 import { cors } from "hono/cors"; 3 3 import { vinyl } from "./routes/vinyl"; 4 4 import { activity } from "./routes/activity"; 5 + import { pollAndCache } from "./lib/lastfm"; 6 + import { syncDiscogs } from "./sync"; 7 + import type { AppContext, Env } from "./env"; 5 8 6 - const app = new Hono(); 9 + const app = new Hono<AppContext>(); 7 10 8 11 app.use( 9 12 "*", ··· 22 25 app.get("/", (c) => c.json({ status: "ok" })); 23 26 24 27 export default { 25 - port: 3000, 26 28 fetch: app.fetch, 29 + 30 + async scheduled( 31 + event: ScheduledEvent, 32 + env: Env, 33 + ctx: ExecutionContext 34 + ): Promise<void> { 35 + // "0 */6 * * *" → Discogs sync; everything else → Last.fm poll. 36 + if (event.cron === "0 */6 * * *") { 37 + ctx.waitUntil(syncDiscogs(env)); 38 + } else { 39 + ctx.waitUntil(pollAndCache(env)); 40 + } 41 + }, 27 42 };
+30 -50
src/lib/lastfm.ts
··· 1 - const LASTFM_API_KEY = process.env.LASTFM_API_KEY!; 2 - const LASTFM_USERNAME = process.env.LASTFM_USERNAME!; 3 - const POLL_INTERVAL = 10_000; 1 + import type { Env } from "../env"; 4 2 5 3 export interface Track { 6 4 name: string; ··· 11 9 nowPlaying: boolean; 12 10 timestamp: string | null; 13 11 } 14 - 15 - type Listener = (track: Track | null) => void; 16 - 17 - let currentTrack: Track | null = null; 18 - const listeners = new Set<Listener>(); 19 - let polling = false; 20 12 21 13 function parseTrack(raw: Record<string, unknown>): Track { 22 14 const artist = raw.artist as Record<string, string>; ··· 39 31 }; 40 32 } 41 33 42 - function trackChanged(a: Track | null, b: Track | null): boolean { 43 - if (a === null || b === null) return a !== b; 44 - return ( 45 - a.name !== b.name || 46 - a.artist !== b.artist || 47 - a.nowPlaying !== b.nowPlaying 48 - ); 34 + /** Fetch the most recent track from Last.fm. */ 35 + export async function fetchCurrentTrack(env: Env): Promise<Track | null> { 36 + const url = `https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=${env.LASTFM_USERNAME}&api_key=${env.LASTFM_API_KEY}&format=json&limit=1`; 37 + const res = await fetch(url); 38 + if (!res.ok) return null; 39 + 40 + const data = (await res.json()) as { 41 + recenttracks?: { track?: Array<Record<string, unknown>> }; 42 + }; 43 + const raw = data?.recenttracks?.track?.[0]; 44 + return raw ? parseTrack(raw) : null; 49 45 } 50 46 51 - async function poll() { 47 + /** Read the cached current track from D1. */ 48 + export async function getCachedTrack(env: Env): Promise<Track | null> { 49 + const row = await env.DB 50 + .prepare("SELECT payload FROM now_playing WHERE id = 1") 51 + .first<{ payload: string | null }>(); 52 + if (!row?.payload) return null; 52 53 try { 53 - const res = await fetch( 54 - `https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=${LASTFM_USERNAME}&api_key=${LASTFM_API_KEY}&format=json&limit=1` 55 - ); 56 - 57 - if (!res.ok) return; 58 - 59 - const data = await res.json(); 60 - const rawTrack = data?.recenttracks?.track?.[0]; 61 - const track = rawTrack ? parseTrack(rawTrack) : null; 62 - 63 - if (trackChanged(currentTrack, track)) { 64 - currentTrack = track; 65 - for (const listener of listeners) { 66 - listener(track); 67 - } 68 - } 69 - } catch (err) { 70 - console.error("Last.fm poll failed:", err); 54 + return JSON.parse(row.payload) as Track; 55 + } catch { 56 + return null; 71 57 } 72 58 } 73 59 74 - function startPolling() { 75 - if (polling) return; 76 - polling = true; 77 - poll(); 78 - setInterval(poll, POLL_INTERVAL); 79 - } 80 - 81 - export function getCurrentTrack(): Track | null { 82 - return currentTrack; 83 - } 84 - 85 - export function subscribe(listener: Listener): () => void { 86 - listeners.add(listener); 87 - startPolling(); 88 - return () => listeners.delete(listener); 60 + /** Poll Last.fm and persist to D1 if changed. */ 61 + export async function pollAndCache(env: Env): Promise<void> { 62 + const track = await fetchCurrentTrack(env); 63 + await env.DB 64 + .prepare( 65 + "UPDATE now_playing SET payload = ?, updated_at = ? WHERE id = 1" 66 + ) 67 + .bind(track ? JSON.stringify(track) : null, Date.now()) 68 + .run(); 89 69 }
+28 -19
src/routes/activity.ts
··· 1 1 import { Hono } from "hono"; 2 2 import { streamSSE } from "hono/streaming"; 3 - import { getCurrentTrack, subscribe } from "../lib/lastfm"; 3 + import { getCachedTrack, type Track } from "../lib/lastfm"; 4 + import type { AppContext } from "../env"; 4 5 5 - const activity = new Hono(); 6 + const activity = new Hono<AppContext>(); 6 7 7 - activity.get("/music", (c) => { 8 - return c.json({ track: getCurrentTrack() }); 8 + activity.get("/music", async (c) => { 9 + const track = await getCachedTrack(c.env); 10 + return c.json({ track }); 9 11 }); 10 12 13 + function trackChanged(a: Track | null, b: Track | null): boolean { 14 + if (a === null || b === null) return a !== b; 15 + return ( 16 + a.name !== b.name || 17 + a.artist !== b.artist || 18 + a.nowPlaying !== b.nowPlaying 19 + ); 20 + } 21 + 11 22 activity.get("/music/stream", (c) => { 12 23 return streamSSE(c, async (stream) => { 13 - const track = getCurrentTrack(); 24 + let last: Track | null = await getCachedTrack(c.env); 25 + 14 26 await stream.writeSSE({ 15 - data: JSON.stringify({ track }), 27 + data: JSON.stringify({ track: last }), 16 28 event: "track", 17 29 }); 18 30 19 - const unsubscribe = subscribe((track) => { 20 - stream.writeSSE({ 21 - data: JSON.stringify({ track }), 22 - event: "track", 23 - }); 24 - }); 25 - 26 - stream.onAbort(() => { 27 - unsubscribe(); 28 - }); 29 - 30 - // Keep the connection open 31 + // Poll the D1 cache every 10s. The cron job updates it every minute. 31 32 while (true) { 32 - await stream.sleep(30_000); 33 + await stream.sleep(10_000); 34 + const next = await getCachedTrack(c.env); 35 + if (trackChanged(last, next)) { 36 + last = next; 37 + await stream.writeSSE({ 38 + data: JSON.stringify({ track: next }), 39 + event: "track", 40 + }); 41 + } 33 42 } 34 43 }); 35 44 });
+24 -24
src/routes/vinyl.ts
··· 1 1 import { Hono } from "hono"; 2 - import { sql } from "../db"; 2 + import type { AppContext } from "../env"; 3 3 4 - const vinyl = new Hono(); 4 + const vinyl = new Hono<AppContext>(); 5 + 6 + const SORT_COLUMNS: Record<string, string> = { 7 + title: "title", 8 + artist: "artist_name", 9 + added: "date_added", 10 + }; 5 11 6 12 vinyl.get("/", async (c) => { 7 13 const page = Math.max(1, Number(c.req.query("page") || "1")); 8 14 const perPage = 20; 9 15 const sort = c.req.query("sort") || "added"; 10 - const order = c.req.query("order") || "desc"; 11 - 12 - const sortColumn: Record<string, string> = { 13 - title: "title", 14 - artist: "artist_name", 15 - added: "date_added", 16 - }; 16 + const order = c.req.query("order") === "asc" ? "ASC" : "DESC"; 17 17 18 - const col = sortColumn[sort] || "date_added"; 19 - const dir = order === "asc" ? "asc" : "desc"; 18 + const col = SORT_COLUMNS[sort] || "date_added"; 20 19 const offset = (page - 1) * perPage; 21 20 22 - const [{ count }] = await sql`SELECT count(*)::int as count FROM releases`; 21 + const countRow = await c.env.DB 22 + .prepare("SELECT count(*) AS count FROM releases") 23 + .first<{ count: number }>(); 24 + const count = countRow?.count ?? 0; 23 25 24 - const releases = await sql` 25 - SELECT 26 - discogs_id, 27 - title, 28 - artist_name, 29 - cover_image, 30 - date_added 31 - FROM releases 32 - ORDER BY ${sql(col)} ${sql.unsafe(dir)} 33 - LIMIT ${perPage} 34 - OFFSET ${offset} 35 - `; 26 + // `col` and `order` are validated against allow-lists above, safe to interpolate. 27 + const { results: releases } = await c.env.DB 28 + .prepare( 29 + `SELECT discogs_id, title, artist_name, cover_image, date_added 30 + FROM releases 31 + ORDER BY ${col} ${order} 32 + LIMIT ? OFFSET ?` 33 + ) 34 + .bind(perPage, offset) 35 + .all(); 36 36 37 37 const pages = Math.ceil(count / perPage); 38 38
+37 -49
src/sync.ts
··· 1 - import { sql } from "./db"; 1 + import type { Env } from "./env"; 2 2 3 - const DISCOGS_TOKEN = process.env.DISCOGS_PERSONAL_ACCESS_TOKEN!; 4 - const DISCOGS_USER = "matthew_hre"; 5 3 const PER_PAGE = 100; 6 4 7 5 function cleanArtistName(name: string): string { ··· 18 16 }; 19 17 } 20 18 21 - async function fetchPage(page: number): Promise<{ 22 - releases: DiscogsRelease[]; 23 - pages: number; 24 - }> { 25 - const url = `https://api.discogs.com/users/${DISCOGS_USER}/collection/folders/0/releases?token=${DISCOGS_TOKEN}&per_page=${PER_PAGE}&page=${page}`; 19 + async function fetchPage( 20 + env: Env, 21 + page: number 22 + ): Promise<{ releases: DiscogsRelease[]; pages: number }> { 23 + const url = `https://api.discogs.com/users/${env.DISCOGS_USER}/collection/folders/0/releases?token=${env.DISCOGS_PERSONAL_ACCESS_TOKEN}&per_page=${PER_PAGE}&page=${page}`; 26 24 27 25 const res = await fetch(url, { 28 26 headers: { "User-Agent": "api.matthew-hre.com/1.0" }, ··· 32 30 const retryAfter = Number(res.headers.get("Retry-After") || "60"); 33 31 console.log(`Rate limited, waiting ${retryAfter}s...`); 34 32 await new Promise((r) => setTimeout(r, retryAfter * 1000)); 35 - return fetchPage(page); 33 + return fetchPage(env, page); 36 34 } 37 35 38 36 if (!res.ok) { 39 37 throw new Error(`Discogs API error: ${res.status}`); 40 38 } 41 39 42 - const data = await res.json(); 43 - return { 44 - releases: data.releases, 45 - pages: data.pagination.pages, 40 + const data = (await res.json()) as { 41 + releases: DiscogsRelease[]; 42 + pagination: { pages: number }; 46 43 }; 44 + 45 + return { releases: data.releases, pages: data.pagination.pages }; 47 46 } 48 47 49 - async function sync() { 48 + export async function syncDiscogs(env: Env): Promise<void> { 50 49 console.log("Starting Discogs sync..."); 51 50 52 - // Create table if it doesn't exist 53 - await sql` 54 - CREATE TABLE IF NOT EXISTS releases ( 55 - discogs_id INTEGER PRIMARY KEY, 56 - title TEXT NOT NULL, 57 - artist_name TEXT NOT NULL, 58 - cover_image TEXT NOT NULL, 59 - date_added TIMESTAMPTZ NOT NULL 60 - ) 61 - `; 62 - 63 51 let page = 1; 64 52 let totalPages = 1; 65 53 let synced = 0; 66 54 55 + const upsert = env.DB.prepare( 56 + `INSERT INTO releases (discogs_id, title, artist_name, cover_image, date_added) 57 + VALUES (?, ?, ?, ?, ?) 58 + ON CONFLICT(discogs_id) DO UPDATE SET 59 + title = excluded.title, 60 + artist_name = excluded.artist_name, 61 + cover_image = excluded.cover_image, 62 + date_added = excluded.date_added` 63 + ); 64 + 67 65 while (page <= totalPages) { 68 - const data = await fetchPage(page); 66 + const data = await fetchPage(env, page); 69 67 totalPages = data.pages; 70 68 71 - for (const release of data.releases) { 72 - await sql` 73 - INSERT INTO releases (discogs_id, title, artist_name, cover_image, date_added) 74 - VALUES ( 75 - ${release.id}, 76 - ${release.basic_information.title}, 77 - ${cleanArtistName(release.basic_information.artists[0]?.name || "Unknown")}, 78 - ${release.basic_information.cover_image}, 79 - ${release.date_added} 80 - ) 81 - ON CONFLICT (discogs_id) DO UPDATE SET 82 - title = EXCLUDED.title, 83 - artist_name = EXCLUDED.artist_name, 84 - cover_image = EXCLUDED.cover_image, 85 - date_added = EXCLUDED.date_added 86 - `; 87 - synced++; 69 + const batch = data.releases.map((r) => 70 + upsert.bind( 71 + r.id, 72 + r.basic_information.title, 73 + cleanArtistName(r.basic_information.artists[0]?.name || "Unknown"), 74 + r.basic_information.cover_image, 75 + r.date_added 76 + ) 77 + ); 78 + 79 + if (batch.length > 0) { 80 + await env.DB.batch(batch); 81 + synced += batch.length; 88 82 } 89 83 90 84 console.log(`Page ${page}/${totalPages} — ${synced} releases synced`); ··· 95 89 } 96 90 97 91 console.log(`Sync complete. ${synced} total releases.`); 98 - await sql.end(); 99 92 } 100 - 101 - sync().catch((err) => { 102 - console.error("Sync failed:", err); 103 - process.exit(1); 104 - });
+2
tsconfig.json
··· 3 3 "target": "ES2022", 4 4 "module": "ES2022", 5 5 "moduleResolution": "bundler", 6 + "lib": ["ES2022"], 7 + "types": ["@cloudflare/workers-types"], 6 8 "strict": true, 7 9 "skipLibCheck": true, 8 10 "noEmit": true,
+23
wrangler.toml
··· 1 + name = "api-matthew-hre" 2 + main = "src/index.ts" 3 + compatibility_date = "2025-04-01" 4 + 5 + routes = [ 6 + { pattern = "api.matthew-hre.com", custom_domain = true } 7 + ] 8 + 9 + [[d1_databases]] 10 + binding = "DB" 11 + database_name = "api" 12 + database_id = "5fbbb0c6-410d-4b15-ac41-94f77fc44a52" 13 + migrations_dir = "migrations" 14 + 15 + [triggers] 16 + crons = [ 17 + "* * * * *", 18 + "0 */6 * * *" 19 + ] 20 + 21 + [vars] 22 + LASTFM_USERNAME = "matthew_hre" 23 + DISCOGS_USER = "matthew_hre"