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:
1 parent
9d063fcbe0
commit
27b1d2fe26
15 files changed
+527
-10
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user