feat(content): serve the mounted content folder under /content (spec 003)

Audio with range requests, library.json, Homer books; LRC parser. Content
stays out of git and the image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 02:46:43 +01:00
1 parent 9d063fcbe0
commit 27b1d2fe26
15 files changed
+527 -10

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
export interface HomerBook {
book: number;
heading: string;
summary: string;
paragraphs: string[];
}
const HEADING = /^BOOK ([IVXL]+)\.?$/;
function romanToNumber(roman: string): number {
const v: Record<string, number> = { I: 1, V: 5, X: 10, L: 50 };
let total = 0;
for (let i = 0; i < roman.length; i++) {
const cur = v[roman[i]!]!;
const next = v[roman[i + 1]!] ?? 0;
total += cur < next ? -cur : cur;
}
return total;
}
/**
* Splits a Project Gutenberg Homer text (Butler's prose translation) into books. Only the text between
* the START/END markers counts. The first paragraph after a heading is the book's summary line. A contents
* list repeats the headings, so for each book number the last occurrence wins.
*/
export function splitBooks(text: string): HomerBook[] {
const lines = text.split(/\r?\n/);
const start = lines.findIndex((l) => l.startsWith("*** START OF"));
const end = lines.findIndex((l) => l.startsWith("*** END OF"));
const body = lines.slice(start + 1, end === -1 ? undefined : end);
const books: HomerBook[] = [];
let current: { heading: string; book: number; paras: string[]; buf: string[] } | null = null;
const flush = () => {
if (current && current.buf.length) current.paras.push(current.buf.join(" "));
if (current) current.buf = [];
};
for (const raw of body) {
const line = raw.trim();
const h = HEADING.exec(line);
if (h) {
flush();
if (current) books.push(toBook(current));
current = { heading: line.replace(/\.$/, ""), book: romanToNumber(h[1]!), paras: [], buf: [] };
} else if (!current) {
continue;
} else if (line === "") {
flush();
} else {
current.buf.push(line);
}
}
flush();
if (current) books.push(toBook(current));
const byNumber = new Map(books.map((b) => [b.book, b]));
return [...byNumber.values()].sort((a, b) => a.book - b.book);
}
function toBook(c: { heading: string; book: number; paras: string[] }): HomerBook {
const [summary = "", ...paragraphs] = c.paras;
return { book: c.book, heading: c.heading, summary, paragraphs };
}
+71
View File
@@ -0,0 +1,71 @@
import { existsSync, readdirSync } from "node:fs";
import { join } from "node:path";
export interface LibrarySong {
track: number;
title: string;
audio: string;
lyrics: { url: string; timed: boolean } | null;
}
export interface LibrarySaga {
name: string;
year: number;
cover: string | null;
songs: LibrarySong[];
}
export interface Library {
sagas: LibrarySaga[];
}
const SONGS_DIR = "Songs and Lyrics";
const SAGA_DIR = /^EPIC - (.+ Saga) \((\d{4})\)$/;
const SONG_FILE = / - (\d{2}) - (.+)\.flac$/;
const STORY_ORDER = ["Troy", "Cyclops", "Ocean", "Circe", "Underworld", "Thunder", "Wisdom", "Vengeance", "Ithaca"];
/** URL under /content/files/ for a path relative to the content folder, each segment encoded. */
export function fileUrl(...segments: string[]): string {
return "/content/files/" + segments.map(encodeURIComponent).join("/");
}
function storyRank(name: string): number {
const i = STORY_ORDER.findIndex((word) => name === `The ${word} Saga`);
return i === -1 ? STORY_ORDER.length : i;
}
/** Scans `<dir>/Songs and Lyrics/EPIC - <Saga> (<year>)/` folders. A missing folder gives no sagas. */
export function scanLibrary(dir: string): Library {
const root = join(dir, SONGS_DIR);
if (!existsSync(root)) return { sagas: [] };
const sagas: LibrarySaga[] = [];
for (const folder of readdirSync(root)) {
const m = SAGA_DIR.exec(folder);
if (!m) continue;
const files = new Set(readdirSync(join(root, folder)));
const songs: LibrarySong[] = [];
for (const file of files) {
const s = SONG_FILE.exec(file);
if (!s) continue;
const base = file.slice(0, -".flac".length);
const lrc = files.has(base + ".lrc") ? base + ".lrc" : null;
const txt = files.has(base + ".txt") ? base + ".txt" : null;
songs.push({
track: Number(s[1]),
title: s[2]!,
audio: fileUrl(SONGS_DIR, folder, file),
lyrics: lrc ? { url: fileUrl(SONGS_DIR, folder, lrc), timed: true }
: txt ? { url: fileUrl(SONGS_DIR, folder, txt), timed: false } : null,
});
}
songs.sort((a, b) => a.track - b.track);
sagas.push({
name: m[1]!,
year: Number(m[2]),
cover: files.has("folder.jpg") ? fileUrl(SONGS_DIR, folder, "folder.jpg") : null,
songs,
});
}
sagas.sort((a, b) => storyRank(a.name) - storyRank(b.name) || a.year - b.year || a.name.localeCompare(b.name));
return { sagas };
}
+83
View File
@@ -0,0 +1,83 @@
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
import { join, resolve, sep } from "node:path";
import { scanLibrary } from "./library";
import { splitBooks, type HomerBook } from "./homer";
const HOMER_FILES = { odyssey: "The Odyssey.txt", iliad: "The Illiad.txt" } as const;
const HOMER_DIR = "Homers the Odyssey Book";
const notFound = () => new Response("Not found", { status: 404 });
// Bun reports FLAC as audio/x-flac; audio/flac is the registered type.
const contentType = (file: Blob) => (file.type.startsWith("audio/x-flac") ? "audio/flac" : file.type);
const bookCache = new Map<string, HomerBook[]>();
function homerBooks(dir: string, work: keyof typeof HOMER_FILES): HomerBook[] | null {
const path = join(dir, HOMER_DIR, HOMER_FILES[work]);
if (!existsSync(path)) return null;
let books = bookCache.get(path);
if (!books) {
books = splitBooks(readFileSync(path, "utf8"));
bookCache.set(path, books);
}
return books;
}
/** Resolves a /content/files/ path inside `dir`; null if it escapes the folder or is not a file. */
function safeFile(dir: string, encoded: string): string | null {
let rel: string;
try {
rel = decodeURIComponent(encoded);
} catch {
return null;
}
const root = realpathSync(dir);
const target = resolve(root, rel);
if (!target.startsWith(root + sep) || !existsSync(target)) return null;
const real = realpathSync(target);
if (!real.startsWith(root + sep) || !statSync(real).isFile()) return null;
return real;
}
function serveFile(path: string, req: Request): Response {
const file = Bun.file(path);
const size = file.size;
const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.get("range") ?? "");
if (!range) return new Response(file, { headers: { "Accept-Ranges": "bytes", "Content-Type": contentType(file) } });
let start = range[1] ? Number(range[1]) : size - Number(range[2]);
let end = range[1] && range[2] ? Number(range[2]) : size - 1;
start = Math.max(0, start);
end = Math.min(end, size - 1);
if (start > end) return new Response(null, { status: 416, headers: { "Content-Range": `bytes */${size}` } });
return new Response(file.slice(start, end + 1), {
status: 206,
headers: {
"Accept-Ranges": "bytes",
"Content-Range": `bytes ${start}-${end}/${size}`,
"Content-Type": contentType(file),
},
});
}
/**
* Handles /content/* from the mounted content folder (see docs/specs/003-content-mount.md).
* Returns null for paths outside /content/; 404 for every /content/ path when no folder is mounted.
*/
export function contentResponse(req: Request, dir: string | undefined): Response | null {
const { pathname } = new URL(req.url);
if (!pathname.startsWith("/content/")) return null;
if (!dir || !existsSync(dir)) return notFound();
if (pathname === "/content/library.json") return Response.json(scanLibrary(dir));
if (pathname.startsWith("/content/files/")) {
const path = safeFile(dir, pathname.slice("/content/files/".length));
return path ? serveFile(path, req) : notFound();
}
const homer = /^\/content\/homer\/(odyssey|iliad)\/(\d+)\.json$/.exec(pathname);
if (homer) {
const book = homerBooks(dir, homer[1] as keyof typeof HOMER_FILES)?.find((b) => b.book === Number(homer[2]));
return book ? Response.json(book) : notFound();
}
return notFound();
}
+10 -3
View File
@@ -1,4 +1,5 @@
import type { Server } from "bun";
import { contentResponse } from "./content/routes";
import home from "./index.html";
import m0 from "./mockups/01-editorial/index.html";
import m1 from "./mockups/02-vertical-story/index.html";
@@ -17,7 +18,13 @@ import m13 from "./mockups/14-voyage-panel/index.html";
import m14 from "./mockups/15-voyage-look2/index.html";
import m15 from "./mockups/16-voyage-readable/index.html";
export function startServer(port: number): Server<undefined> {
export interface ServerOptions {
/** Folder with the real content (audio, lyrics, Homer); defaults to CONTENT_DIR. See spec 003. */
contentDir?: string;
}
export function startServer(port: number, opts: ServerOptions = {}): Server<undefined> {
const contentDir = "contentDir" in opts ? opts.contentDir : process.env.CONTENT_DIR;
return Bun.serve({
port,
routes: {
@@ -56,8 +63,8 @@ export function startServer(port: number): Server<undefined> {
"/mockups/16-voyage-readable": Response.redirect("/mockups/16-voyage-readable/", 302),
},
development: process.env.NODE_ENV !== "production",
fetch() {
return new Response("Not found", { status: 404 });
fetch(req) {
return contentResponse(req, contentDir) ?? new Response("Not found", { status: 404 });
},
});
}
+20
View File
@@ -0,0 +1,20 @@
export interface LrcLine {
time: number;
text: string;
}
const STAMP = /\[(\d+):(\d{1,2}(?:\.\d+)?)\]/g;
/** Parses LRC timed lyrics: metadata tags and untimed lines are skipped, output is sorted by time. */
export function parseLrc(text: string): LrcLine[] {
const lines: LrcLine[] = [];
for (const raw of text.split(/\r?\n/)) {
const stamps = [...raw.matchAll(STAMP)];
if (stamps.length === 0) continue;
const lyric = raw.replace(STAMP, "").trim();
for (const [, min, sec] of stamps) {
lines.push({ time: Math.round((Number(min) * 60 + Number(sec)) * 1000) / 1000, text: lyric });
}
}
return lines.sort((a, b) => a.time - b.time);
}