v3: api/ gets one layout rule and keeps to it, and whatever crosses the wire is defined once, in shared/ — api/model/interfaces.ts and src/model.ts declared the same 40 types by hand and had drifted: the error envelope, email required on one side and optional on the other, Invitee/InviteTreeEntry and LinkPreview/UrlPreviewResponse for one shape each. The validation limits, the role permissions, the chat length cap, the @mention pattern and the Bandcamp and YouTube player responses were copied too, behind "keep in sync" comments. shared/ now holds them once, one file per domain (api, category, chat, comment, dump, follow, mention, notification, permissions, player, playlist, search, user, validation, ws), and imports nothing from either half. The server sends Dates; the client reads Wire<T>, the same type with every Date an ISO string, and turns it back with src/utils/shared/deserialize.ts. APIErrorCode becomes a const object, since the frontend's erasableSyntaxOnly rejects an enum. The shared types showed two real gaps: the avatar upload and GET /api/users/me return the full self user and the frontend read them as public users, and a stored session may predate media tokens, so the client keeps it as its own Session type. The backend rule: each folder holds one kind of module, each file is named after its domain. routes/ parse and answer; services/ hold the logic and every SQL query (lib/auth.ts's token check moves to user-service); remote/ is the only place that fetches from other sites — the guarded fetch and the HTML scraping out of rich-content-service, public-address, the Bandcamp and YouTube resolvers, the link providers, the image proxy (fetch, checks and cache, out of routes/preview.ts) and the Twemoji glyphs (out of lib/site-icons.ts). model/ holds per table a row type, its guard and its mappers; validators/ the request guards; db/connection.ts the handle and transaction(), db/startup.ts the purges and default admin that db.ts ran on import, called at the same moment. Tests mirror the source one file per module, test/<layer>/<module>.test.ts, with shared/'s under shared/test/ (deno task test runs both); the test that covered the bandcamp and soundcloud providers together is two now, and the backend can() has its own, so 202 tests. deno lint src/ is clean too: window.* becomes globalThis.*, and test bodies and act() callbacks that never awaited lose their async. The README states the rule. No behaviour changes; the catalogues only move their source references.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-27 20:19:48 +00:00
parent e9f6a1f23e
commit d392be7cb8
269 changed files with 6712 additions and 7122 deletions

View File

@@ -0,0 +1,136 @@
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import { fetchWithTimeout, isValidHttpUrl, readTextCapped } from "./fetch.ts";
import { BANDCAMP_HOST_RE, parseTralbum } from "./bandcamp-tralbum.ts";
import type { Tralbum } from "../../shared/player.ts";
/**
* Resolves a Bandcamp page to its streamable tracks, at play time.
*
* Bandcamp's signed stream URLs expire 24h after the page fetch, so they can
* never be baked into `rich_content` at dump-creation time the way `embedUrl`
* is. This service is the play-time counterpart: the frontend asks for a page,
* gets a whole tracklist back, and plays the mp3s directly from bcbits.
*/
/** Comfortably inside the 24h signature life, so a cache hit is never a URL
* that is about to expire mid-listen. */
const TTL_MS = 12 * 60 * 60 * 1000;
const MAX_ENTRIES = 200;
const cache = new Map<string, Tralbum>();
/** Cache key: a page is the same page regardless of query, fragment or case. */
function cacheKey(url: string): string {
try {
const u = new URL(url);
return `${u.protocol}//${u.hostname.toLowerCase()}${
u.pathname.replace(/\/+$/, "")
}`;
} catch {
return url;
}
}
function readCache(key: string): Tralbum | null {
const hit = cache.get(key);
if (!hit) return null;
if (Date.now() - hit.resolvedAt > TTL_MS) {
cache.delete(key);
return null;
}
return hit;
}
function writeCache(key: string, value: Tralbum): void {
// Map preserves insertion order, so the first key is the oldest write.
if (cache.size >= MAX_ENTRIES) {
const oldest = cache.keys().next().value;
if (oldest !== undefined) cache.delete(oldest);
}
cache.set(key, value);
}
/**
* Fetch and parse a Bandcamp page.
*
* `force` bypasses the cache — used by the client's re-resolve path, so a
* cached entry whose URLs have started 403ing can't keep being handed back.
*/
export async function resolveBandcamp(
pageUrl: string,
{ force = false }: { force?: boolean } = {},
): Promise<Tralbum> {
if (!isValidHttpUrl(pageUrl)) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Invalid URL",
);
}
let hostname: string;
try {
hostname = new URL(pageUrl).hostname;
} catch {
throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, "Invalid URL");
}
if (!BANDCAMP_HOST_RE.test(hostname)) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Not a Bandcamp URL",
);
}
const key = cacheKey(pageUrl);
if (!force) {
const hit = readCache(key);
if (hit) return hit;
}
let res: Response;
try {
res = await fetchWithTimeout(pageUrl, 8000);
} catch {
throw new APIException(
APIErrorCode.SERVER_ERROR,
502,
"Could not reach Bandcamp",
);
}
// Every path out of here that doesn't read the body has to release it, or the
// connection stays open until GC. This endpoint is unauthenticated: a caller
// hammering random /track/<slug> URLs would otherwise leak one body a request.
if (!res.ok) {
await res.body?.cancel();
throw new APIException(
APIErrorCode.SERVER_ERROR,
502,
`Bandcamp returned ${res.status}`,
);
}
if (!(res.headers.get("content-type") ?? "").startsWith("text/html")) {
await res.body?.cancel();
throw new APIException(
APIErrorCode.SERVER_ERROR,
502,
"Bandcamp did not return a page",
);
}
const parsed = parseTralbum(await readTextCapped(res), pageUrl);
if (!parsed) {
// Covers "no data-tralbum", "hasAudio: false" and preorder-only releases
// with nothing streamable — all cases where the client falls back to the
// iframe embed rather than showing an error.
throw new APIException(
APIErrorCode.NOT_FOUND,
404,
"No streamable tracks on this page",
);
}
writeCache(key, parsed);
return parsed;
}