An API for my personal portfolio
0
fork

Configure Feed

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

feat: setup basic app

+237
+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 + }
+17
package.json
··· 1 + { 2 + "name": "api.matthew-hre.com", 3 + "version": "0.1.0", 4 + "private": true, 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 + "dependencies": { 11 + "hono": "^4.7.10", 12 + "postgres": "^3.4.7" 13 + }, 14 + "devDependencies": { 15 + "@types/bun": "^1.2.14" 16 + } 17 + }
+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 };
+21
src/index.ts
··· 1 + import { Hono } from "hono"; 2 + import { cors } from "hono/cors"; 3 + import { vinyl } from "./routes/vinyl"; 4 + 5 + const app = new Hono(); 6 + 7 + app.use( 8 + "*", 9 + cors({ 10 + origin: ["https://matthew-hre.com", "https://beta.matthew-hre.com"], 11 + }) 12 + ); 13 + 14 + app.route("/vinyl", vinyl); 15 + 16 + app.get("/", (c) => c.json({ status: "ok" })); 17 + 18 + export default { 19 + port: 3000, 20 + fetch: app.fetch, 21 + };
+45
src/routes/vinyl.ts
··· 1 + import { Hono } from "hono"; 2 + import { sql } from "../db"; 3 + 4 + const vinyl = new Hono(); 5 + 6 + vinyl.get("/", async (c) => { 7 + const page = Math.max(1, Number(c.req.query("page") || "1")); 8 + const perPage = 20; 9 + 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 + }; 17 + 18 + const col = sortColumn[sort] || "date_added"; 19 + const dir = order === "asc" ? "asc" : "desc"; 20 + const offset = (page - 1) * perPage; 21 + 22 + const [{ count }] = await sql`SELECT count(*)::int as count FROM releases`; 23 + 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 + `; 36 + 37 + const pages = Math.ceil(count / perPage); 38 + 39 + return c.json({ 40 + pagination: { page, pages, per_page: perPage, items: count }, 41 + releases, 42 + }); 43 + }); 44 + 45 + export { vinyl };
+100
src/sync.ts
··· 1 + import { sql } from "./db"; 2 + 3 + const DISCOGS_TOKEN = process.env.DISCOGS_PERSONAL_ACCESS_TOKEN!; 4 + const DISCOGS_USER = "matthew_hre"; 5 + const PER_PAGE = 100; 6 + 7 + interface DiscogsRelease { 8 + id: number; 9 + date_added: string; 10 + basic_information: { 11 + title: string; 12 + cover_image: string; 13 + artists: { name: string }[]; 14 + }; 15 + } 16 + 17 + async function fetchPage(page: number): Promise<{ 18 + releases: DiscogsRelease[]; 19 + pages: number; 20 + }> { 21 + const url = `https://api.discogs.com/users/${DISCOGS_USER}/collection/folders/0/releases?token=${DISCOGS_TOKEN}&per_page=${PER_PAGE}&page=${page}`; 22 + 23 + const res = await fetch(url, { 24 + headers: { "User-Agent": "api.matthew-hre.com/1.0" }, 25 + }); 26 + 27 + if (res.status === 429) { 28 + const retryAfter = Number(res.headers.get("Retry-After") || "60"); 29 + console.log(`Rate limited, waiting ${retryAfter}s...`); 30 + await new Promise((r) => setTimeout(r, retryAfter * 1000)); 31 + return fetchPage(page); 32 + } 33 + 34 + if (!res.ok) { 35 + throw new Error(`Discogs API error: ${res.status}`); 36 + } 37 + 38 + const data = await res.json(); 39 + return { 40 + releases: data.releases, 41 + pages: data.pagination.pages, 42 + }; 43 + } 44 + 45 + async function sync() { 46 + console.log("Starting Discogs sync..."); 47 + 48 + // Create table if it doesn't exist 49 + await sql` 50 + CREATE TABLE IF NOT EXISTS releases ( 51 + discogs_id INTEGER PRIMARY KEY, 52 + title TEXT NOT NULL, 53 + artist_name TEXT NOT NULL, 54 + cover_image TEXT NOT NULL, 55 + date_added TIMESTAMPTZ NOT NULL 56 + ) 57 + `; 58 + 59 + let page = 1; 60 + let totalPages = 1; 61 + let synced = 0; 62 + 63 + while (page <= totalPages) { 64 + const data = await fetchPage(page); 65 + totalPages = data.pages; 66 + 67 + for (const release of data.releases) { 68 + await sql` 69 + INSERT INTO releases (discogs_id, title, artist_name, cover_image, date_added) 70 + VALUES ( 71 + ${release.id}, 72 + ${release.basic_information.title}, 73 + ${release.basic_information.artists[0]?.name || "Unknown"}, 74 + ${release.basic_information.cover_image}, 75 + ${release.date_added} 76 + ) 77 + ON CONFLICT (discogs_id) DO UPDATE SET 78 + title = EXCLUDED.title, 79 + artist_name = EXCLUDED.artist_name, 80 + cover_image = EXCLUDED.cover_image, 81 + date_added = EXCLUDED.date_added 82 + `; 83 + synced++; 84 + } 85 + 86 + console.log(`Page ${page}/${totalPages} — ${synced} releases synced`); 87 + page++; 88 + 89 + // Be nice to the Discogs API 90 + await new Promise((r) => setTimeout(r, 1000)); 91 + } 92 + 93 + console.log(`Sync complete. ${synced} total releases.`); 94 + await sql.end(); 95 + } 96 + 97 + sync().catch((err) => { 98 + console.error("Sync failed:", err); 99 + process.exit(1); 100 + });
+18
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ES2022", 4 + "module": "ES2022", 5 + "moduleResolution": "bundler", 6 + "strict": true, 7 + "skipLibCheck": true, 8 + "noEmit": true, 9 + "esModuleInterop": true, 10 + "resolveJsonModule": true, 11 + "isolatedModules": true, 12 + "paths": { 13 + "@/*": ["./src/*"] 14 + } 15 + }, 16 + "include": ["src/**/*.ts"], 17 + "exclude": ["node_modules"] 18 + }