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:
co-authored by
Claude Opus 5.5
parent
9d063fcbe0
commit
27b1d2fe26
@@ -5,3 +5,7 @@ dist
|
||||
.worktrees
|
||||
screenshots
|
||||
.env*
|
||||
epic-the-musical-content
|
||||
content
|
||||
**/*.flac
|
||||
**/*.lrc
|
||||
|
||||
@@ -6,3 +6,9 @@ CLAUDE.local.md
|
||||
screenshots/
|
||||
.superpowers/
|
||||
.claude/settings.local.json
|
||||
|
||||
# Real content is mounted at runtime, never committed (spec 003)
|
||||
epic-the-musical-content/
|
||||
/content/
|
||||
*.flac
|
||||
*.lrc
|
||||
|
||||
@@ -9,11 +9,11 @@ Deeper context on demand: `docs/ARCHITECTURE.md`, `docs/DECISIONS.md`, `docs/TOD
|
||||
## Commands
|
||||
|
||||
Install: `bun install --frozen-lockfile`
|
||||
Dev: `bun run dev` (`PORT` env, default 3000)
|
||||
Dev: `bun run dev` (`PORT` env, default 3000; `CONTENT_DIR=~/epic-the-musical-content` for real audio/lyrics)
|
||||
Test: `bun test` | Single file: `bun test tests/data.test.ts` | Single case: `bun test -t "registry has 16"`
|
||||
Typecheck: `bun run typecheck`
|
||||
Build: `bun run build` | Run the build: `bun run start`
|
||||
Docker: `docker build -t epic-timeline .` then `docker run --rm -p 3000:3000 epic-timeline`
|
||||
Docker: `docker build -t epic-timeline .` then `docker run --rm -p 3000:3000 -v ~/epic-the-musical-content:/content:ro -e CONTENT_DIR=/content epic-timeline`
|
||||
|
||||
Run the production bundle **from inside `dist/`**. Bun resolves bundled assets relative to the cwd:
|
||||
```sh
|
||||
@@ -31,6 +31,8 @@ bun dist/server.js # wrong: crashes with 'Bundled file "./index-….js" no
|
||||
only when the plan says so.
|
||||
- No two mockups may have byte-identical `style.css` — the build fails with
|
||||
"Multiple files share the same output path". Give each one its own styles from the start.
|
||||
- Real audio, lyrics and cover art are Jorge Rivera-Herrans' copyrighted work: they live only in the
|
||||
mounted content folder (`CONTENT_DIR`, served under `/content/`), never in git or the Docker image.
|
||||
- Sample data (`src/data/sample.ts`): lyrics are placeholders, never real lyrics. Any fact, story time
|
||||
or Odyssey reference you can't confirm gets `// TODO(verify)`.
|
||||
- Assets are bundled, never loaded from a CDN: fonts from `@fontsource/*`, three.js from npm.
|
||||
@@ -68,7 +70,8 @@ Mockup rounds — any UI change beyond a trivial tweak, while brainstorming and
|
||||
|
||||
`src/server.ts` -> `Bun.serve` with HTML-import routes: `/` and `/mockups/NN-slug/`
|
||||
`src/data/` -> `types.ts` (shapes), `sample.ts` (shared sample data), `query.ts` (helpers)
|
||||
`src/shared/` -> credits, audio slot (direct MP3, empty state), YouTube link cards
|
||||
`src/shared/` -> credits, audio slot (direct MP3, empty state), YouTube link cards, LRC parser
|
||||
`src/content/` -> `/content/*` routes over the mounted content folder: library.json, files (ranges), Homer books
|
||||
`src/mockups/NN-slug/` -> one design direction each: `index.html`, `main.ts`, `style.css`, plus optional helper modules (e.g. `scene.ts`, `layout.ts`)
|
||||
`tests/` -> data invariants, shared DOM modules (happy-dom), route smoke tests
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ shared credits snippet.
|
||||
## Map
|
||||
| Path | Responsibility |
|
||||
|---|---|
|
||||
| `src/server.ts` | `startServer(port)`: routes `/`, `/mockups/NN-slug/` (+ 302 from the no-slash form), 404 otherwise. Runs when `import.meta.main`; `PORT` env, default 3000. |
|
||||
| `src/server.ts` | `startServer(port, { contentDir? })`: routes `/`, `/mockups/NN-slug/` (+ 302 from the no-slash form), `/content/*` via `src/content/routes.ts`, 404 otherwise. Runs when `import.meta.main`; `PORT` env, default 3000. |
|
||||
| `src/index.html`, `src/index.ts`, `src/index.css` | Index page: static cards linking each mockup, then `mountCredits()`. |
|
||||
| `src/mockups/registry.ts` | `mockups: MockupInfo[]` — slug, number, name, pitch, what to try. |
|
||||
| `src/mockups/NN-slug/` | One design direction: `index.html` (links `../../shared/credits.css`, has `<footer data-fan-credits>`), `main.ts`, `style.css`, plus optional helper modules (e.g. `scene.ts`, `layout.ts`). |
|
||||
@@ -22,7 +22,11 @@ shared credits snippet.
|
||||
| `src/shared/audio-slot.ts` | `createAudioSlot(song)`: `<audio>` for `song.audio`, else an empty-state slot. |
|
||||
| `src/shared/video-cards.ts` | `createVideoCards(song)`: YouTube link cards, no embeds. |
|
||||
| `src/shared/credits.css` | Styles for credits, audio slot, video cards. |
|
||||
| `tests/` | `data.test.ts` (dataset invariants), `shared.test.ts` (DOM modules under happy-dom), `server.test.ts` (every route 200s, index links every registry slug, each mockup has the credits hook). |
|
||||
| `src/content/routes.ts` | `contentResponse(req, dir)`: `/content/library.json`, `/content/files/<path>` (traversal-guarded, HTTP ranges), `/content/homer/<odyssey\|iliad>/<book>.json`. All 404 when no folder is mounted. |
|
||||
| `src/content/library.ts` | `scanLibrary(dir)`: sagas (story order) and songs from `Songs and Lyrics/EPIC - <Saga> (<year>)/`; `.lrc` preferred over `.txt`. |
|
||||
| `src/content/homer.ts` | `splitBooks(text)`: Gutenberg Butler text → books (heading, summary, paragraphs). |
|
||||
| `src/shared/lrc.ts` | `parseLrc(text)`: timed lyric lines, sorted. |
|
||||
| `tests/` | `data.test.ts` (dataset invariants), `shared.test.ts` (DOM modules under happy-dom), `server.test.ts` (every route 200s, index links every registry slug, each mockup has the credits hook), `content.test.ts` (LRC, library scan, Homer split, `/content/*` routes on a temp fixture). |
|
||||
| `Dockerfile` | Two-stage `oven/bun:1.3`: install + build, then copy `dist/` and run from `WORKDIR /app/dist`. |
|
||||
|
||||
## Data flow
|
||||
@@ -32,7 +36,9 @@ shared credits snippet.
|
||||
3. `main.ts` renders the direction and calls `mountCredits()` into `<footer data-fan-credits>`.
|
||||
|
||||
## External dependencies
|
||||
None at runtime. Fonts (`@fontsource/*`) and three.js are bundled from npm. Outbound links only: YouTube
|
||||
Optional at runtime: the content folder named by `CONTENT_DIR` (real audio, lyrics, cover art, Butler's
|
||||
Odyssey and Iliad). It is mounted read-only, never committed or baked into the image (spec 003). Without it
|
||||
the site works and `/content/*` answers 404. Otherwise none. Fonts (`@fontsource/*`) and three.js are bundled from npm. Outbound links only: YouTube
|
||||
(video cards) and Project Gutenberg (Odyssey references) — the site works without them.
|
||||
|
||||
## Invariants
|
||||
|
||||
@@ -66,3 +66,11 @@ persistent side panel. No new dependency.
|
||||
## 2026-10-30 — three.js allowed in mockup 16
|
||||
|
||||
**Decision:** mockup 16 (Voyage, readable) may use three.js; it iterates on 15. No new dependency.
|
||||
|
||||
## 2026-10-30 — Real content is mounted, never committed (spec 003)
|
||||
|
||||
**Decision:** the real audio, timed lyrics, cover art and Homer texts stay in a folder outside the repo
|
||||
(`~/epic-the-musical-content` on the dev host), served under `/content/` from `CONTENT_DIR` and mounted
|
||||
read-only into the container. **Why:** the audio, lyrics and art are Jorge Rivera-Herrans' copyrighted
|
||||
work; committing them would push them to Gitea and bake 1.5 GB into the image. The user chose this
|
||||
("Mount it, never commit"). Range requests are handled in `src/content/routes.ts` so audio can seek.
|
||||
|
||||
@@ -85,3 +85,6 @@ Small, specific, independently actionable. Remove only when actually done.
|
||||
|
||||
- Mockup 14: the cave-mouth mask may repeat as holes during the curtain drop (Chrome, oversized mask
|
||||
region). Fixed in 15 by shrinking the region; 14 left as is (superseded).
|
||||
|
||||
- Homer books: the last book (Odyssey XXIV, 250 paragraphs) also swallows the translator's notes and
|
||||
index after it. Trim at the notes heading before showing whole books.
|
||||
|
||||
@@ -113,7 +113,8 @@ This file exists because of that request. It gets updated after every user messa
|
||||
2. Dispatch one agent per spike, at most 3 at a time. Point each at its report file and the brief, and tell it to continue from the checklist.
|
||||
3. Build 11.
|
||||
4. Rebuild the preview container from this branch:
|
||||
`docker build -t epic-timeline . && docker rm -f epic-timeline-preview && docker run -d --restart unless-stopped -e PORT=3000 -p 10.20.20.2:8099:3000 --name epic-timeline-preview epic-timeline`
|
||||
`docker build -t epic-timeline . && docker rm -f epic-timeline-preview && docker run -d --restart unless-stopped -e PORT=3000 -e CONTENT_DIR=/content -v /home/user/epic-the-musical-content:/content:ro -p 10.20.20.2:8099:3000 --name epic-timeline-preview epic-timeline`
|
||||
(the content mount was added 2026-10-30, spec 003)
|
||||
5. Ask the user which chart approach reads best.
|
||||
|
||||
## 2026-10-29
|
||||
@@ -304,6 +305,11 @@ What is in `~/epic-the-musical-content/` (1.5 GB, checked 2026-10-30):
|
||||
Docker image**; the server reads it from a `CONTENT_DIR` path, mounted read-only into the container.
|
||||
Needs the user's OK (it touches `src/server.ts`, the Dockerfile and the lyrics rule).
|
||||
|
||||
- **User's answers (2026-10-30):** content access: **"Mount it, never commit"**; next step: **"Yes, in 17
|
||||
after 16"** (16 finishes with its simulated player, then 17 uses real FLAC playback, line-synced
|
||||
`.lrc` lyrics and the actual Odyssey passage). Implemented as spec `docs/specs/003-content-mount.md`.
|
||||
- Note: Butler's translation uses Roman names (Ulysses, Minerva, Jove). Worth a line in the UI.
|
||||
|
||||
## Open questions (in priority order)
|
||||
|
||||
1. ~~Which sea-chart approach (08–11) reads best, or which mix?~~ 11's navigation + 10's style (§9).
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
status: in-progress
|
||||
---
|
||||
|
||||
# Plan 003 — content mount
|
||||
|
||||
Spec: `docs/specs/003-content-mount.md`. TDD: write the test, see it fail, implement, see it pass.
|
||||
|
||||
- [x] 1. `src/shared/lrc.ts` — `parseLrc(text): { time: number; text: string }[]`. Tests in
|
||||
`tests/content.test.ts`: basic lines, `[mm:ss]` and `[mm:ss.xx]`/`[mm:ss.xxx]`, multiple stamps on
|
||||
one line, `[ar:…]`-style metadata skipped, CRLF, sorted output.
|
||||
- [x] 2. `src/content/library.ts` — `scanLibrary(dir)`: saga folders `EPIC - <Name> (<year>)`, song files
|
||||
`… - NN - <Title>.flac`, sagas in story order (Troy, Cyclops, Ocean, Circe, Underworld, Thunder,
|
||||
Wisdom, Vengeance, Ithaca; unknown names after, by year then name), lyrics `.lrc` preferred else `.txt`, `folder.jpg` cover. URLs under
|
||||
`/content/files/` with each path segment URI-encoded. Fixture folder built in a temp dir by the test.
|
||||
- [x] 3. `src/content/homer.ts` — `splitBooks(text)`: between the Gutenberg START/END markers, headings
|
||||
`BOOK <roman>` (optional trailing dot), CRLF-safe; returns `{ book, heading, summary, paragraphs }[]`.
|
||||
Test with a small fixture string.
|
||||
- [x] 4. `src/content/routes.ts` — `contentResponse(req, dir | undefined): Response | null` handling the
|
||||
three routes, traversal guard, range requests (206 with Content-Range). Wire into `src/server.ts`
|
||||
`fetch()`. Route tests via `startServer` with `CONTENT_DIR` pointing at the fixture, and without it.
|
||||
- [x] 5. `.gitignore` / `.dockerignore`: ignore `epic-the-musical-content/`, `content/`, `*.flac`, `*.lrc`.
|
||||
- [x] 6. Docs: AGENTS.md (lyrics rule, content line in Architecture/Commands), `docs/ARCHITECTURE.md`,
|
||||
`docs/DECISIONS.md`, `docs/design/002-direction-round-notes.md` §7 run command.
|
||||
- [x] 7. Verify: `bun test`, `bun run typecheck`, `bun run build`; rebuild the preview container with the
|
||||
mount; curl library.json (9 sagas, 40 songs), a range request on a FLAC, an Odyssey book.
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
status: approved
|
||||
---
|
||||
|
||||
# Real content from a mounted folder (audio, timed lyrics, Homer)
|
||||
|
||||
Status: approved by user 2026-10-30 · Date: 2026-10-30
|
||||
|
||||
## Intent
|
||||
|
||||
The user added the real content in `~/epic-the-musical-content/`: all 40 *EPIC* songs as FLAC, timed
|
||||
lyrics (`.lrc`) for 37 of them and plain-text lyrics for 3, saga cover art, and Samuel Butler's
|
||||
public-domain prose translations of the *Odyssey* and the *Iliad* (Project Gutenberg #1727, #2199).
|
||||
Mockups (starting with 17) and later the real app should use this content: real playback, line-synced
|
||||
lyrics, and the actual Odyssey passage for a song.
|
||||
|
||||
The audio, lyrics and cover art are Jorge Rivera-Herrans' copyrighted work. The user chose (2026-10-30):
|
||||
**"Mount it, never commit"** — the folder stays out of git and out of the Docker image; the server reads
|
||||
it from a path given by `CONTENT_DIR`, mounted read-only into the container.
|
||||
|
||||
## Goals
|
||||
|
||||
1. **Server serves the mounted folder** when `CONTENT_DIR` is set:
|
||||
- `GET /content/library.json` — every saga (name, year, cover URL) with its songs (track, title,
|
||||
audio URL, lyrics URL and whether they are timed), built by scanning the folder.
|
||||
- `GET /content/files/<path>` — the file at that path inside `CONTENT_DIR`, with the right content
|
||||
type and HTTP range support so audio can seek. Paths outside `CONTENT_DIR` are refused.
|
||||
- `GET /content/homer/<odyssey|iliad>/<book>.json` — one book of the Butler text: number, heading,
|
||||
summary line, paragraphs.
|
||||
- Without `CONTENT_DIR` (or with a missing folder), all `/content/*` routes answer 404 and every
|
||||
other route works as before.
|
||||
2. **Shared helpers** for clients: an LRC parser (timed lines, sorted, multi-stamp lines, metadata tags
|
||||
skipped) and the library types.
|
||||
3. **Nothing copyrighted is committed or baked into the image.** `.gitignore` and `.dockerignore`
|
||||
guard against a copy inside the repo.
|
||||
4. **Docs**: AGENTS.md lyrics rule updated (real lyrics only from the mounted folder, never in the
|
||||
repo); ARCHITECTURE, DECISIONS; the preview container run command mounts the folder.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Changing existing mockups 01–16 to use real content.
|
||||
- Transcoding FLAC (browsers play FLAC); caching or a CDN.
|
||||
- Mapping songs to Odyssey passages (that is content work for the real app).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `bun test` covers: library scan of a fixture folder (sagas in story order, songs by track, `.lrc`
|
||||
preferred over `.txt`), LRC parsing, Homer book splitting, the `/content/*` routes with and without `CONTENT_DIR`,
|
||||
path-traversal refusal, and a range request returning 206.
|
||||
- The preview container, run with `-v ~/epic-the-musical-content:/content:ro -e CONTENT_DIR=/content`,
|
||||
serves `/content/library.json` listing 9 sagas and 40 songs, and an audio file plays with seeking.
|
||||
- `git status` shows no content files; the Docker image contains none.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Server } from "bun";
|
||||
import { parseLrc } from "../src/shared/lrc";
|
||||
import { scanLibrary } from "../src/content/library";
|
||||
import { splitBooks } from "../src/content/homer";
|
||||
import { startServer } from "../src/server";
|
||||
|
||||
describe("parseLrc", () => {
|
||||
test("parses timed lines, skips metadata, sorts, handles CRLF and multiple stamps", () => {
|
||||
const text = "[ar:Someone]\r\n[00:12.50]Second\r\n[00:01.2]First\r\n[01:02.345][00:30]Twice\r\n\r\nno stamp\r\n";
|
||||
expect(parseLrc(text)).toEqual([
|
||||
{ time: 1.2, text: "First" },
|
||||
{ time: 12.5, text: "Second" },
|
||||
{ time: 30, text: "Twice" },
|
||||
{ time: 62.345, text: "Twice" },
|
||||
]);
|
||||
});
|
||||
test("keeps empty timed lines as blank text", () => {
|
||||
expect(parseLrc("[00:05.00]")).toEqual([{ time: 5, text: "" }]);
|
||||
});
|
||||
});
|
||||
|
||||
const BOOK_FIXTURE = [
|
||||
"The Project Gutenberg eBook of The Odyssey",
|
||||
"BOOK I (in the contents, before the start marker)",
|
||||
"*** START OF THE PROJECT GUTENBERG EBOOK THE ODYSSEY ***",
|
||||
"Preface text.",
|
||||
"Contents",
|
||||
"BOOK I",
|
||||
"BOOK II.",
|
||||
"",
|
||||
"BOOK I",
|
||||
"",
|
||||
"",
|
||||
"THE GODS IN COUNCIL.",
|
||||
"",
|
||||
"",
|
||||
"Tell me, O Muse, of that",
|
||||
"ingenious hero.",
|
||||
"",
|
||||
"So he spoke.",
|
||||
"BOOK II.",
|
||||
"",
|
||||
"ASSEMBLY.",
|
||||
"",
|
||||
"Now when the child of morning appeared.",
|
||||
"*** END OF THE PROJECT GUTENBERG EBOOK THE ODYSSEY ***",
|
||||
"License text.",
|
||||
].join("\r\n");
|
||||
|
||||
describe("splitBooks", () => {
|
||||
test("splits between the Gutenberg markers into books with summary and paragraphs", () => {
|
||||
const books = splitBooks(BOOK_FIXTURE);
|
||||
expect(books).toHaveLength(2);
|
||||
expect(books[0]).toEqual({
|
||||
book: 1,
|
||||
heading: "BOOK I",
|
||||
summary: "THE GODS IN COUNCIL.",
|
||||
paragraphs: ["Tell me, O Muse, of that ingenious hero.", "So he spoke."],
|
||||
});
|
||||
expect(books[1]).toMatchObject({ book: 2, summary: "ASSEMBLY.", paragraphs: ["Now when the child of morning appeared."] });
|
||||
});
|
||||
});
|
||||
|
||||
let dir: string;
|
||||
beforeAll(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "epic-content-"));
|
||||
const cyc = join(dir, "Songs and Lyrics", "EPIC - The Cyclops Saga (2023)");
|
||||
const troy = join(dir, "Songs and Lyrics", "EPIC - The Troy Saga (2022)");
|
||||
const wis = join(dir, "Songs and Lyrics", "EPIC - The Wisdom Saga (2024)");
|
||||
for (const d of [cyc, troy, wis]) mkdirSync(d, { recursive: true });
|
||||
const f = (saga: string, n: string, t: string) => `Jorge Rivera-Herrans - EPIC - ${saga} - ${n} - ${t}`;
|
||||
writeFileSync(join(cyc, f("The Cyclops Saga", "02", "Survive") + ".flac"), "x".repeat(1000));
|
||||
writeFileSync(join(cyc, f("The Cyclops Saga", "02", "Survive") + ".lrc"), "[00:01.00]a");
|
||||
writeFileSync(join(cyc, f("The Cyclops Saga", "01", "Polyphemus") + ".flac"), "0123456789");
|
||||
writeFileSync(join(cyc, f("The Cyclops Saga", "01", "Polyphemus") + ".lrc"), "[00:01.00]a");
|
||||
writeFileSync(join(cyc, f("The Cyclops Saga", "01", "Polyphemus") + ".txt"), "plain");
|
||||
writeFileSync(join(cyc, "folder.jpg"), "jpg");
|
||||
writeFileSync(join(troy, f("The Troy Saga", "01", "The Horse and the Infant") + ".flac"), "x");
|
||||
writeFileSync(join(wis, f("The Wisdom Saga", "01", "Legendary") + ".flac"), "x");
|
||||
writeFileSync(join(wis, f("The Wisdom Saga", "01", "Legendary") + ".txt"), "plain lyrics");
|
||||
mkdirSync(join(dir, "Homers the Odyssey Book"));
|
||||
writeFileSync(join(dir, "Homers the Odyssey Book", "The Odyssey.txt"), BOOK_FIXTURE);
|
||||
writeFileSync(join(dir, "outside-secret.txt"), "no");
|
||||
});
|
||||
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
describe("scanLibrary", () => {
|
||||
test("lists sagas in story order with songs by track, lrc preferred over txt", () => {
|
||||
const lib = scanLibrary(dir);
|
||||
expect(lib.sagas.map((s) => s.name)).toEqual(["The Troy Saga", "The Cyclops Saga", "The Wisdom Saga"]);
|
||||
const cyc = lib.sagas[1]!;
|
||||
expect(cyc.year).toBe(2023);
|
||||
expect(cyc.cover).toBe("/content/files/Songs%20and%20Lyrics/EPIC%20-%20The%20Cyclops%20Saga%20(2023)/folder.jpg");
|
||||
expect(cyc.songs.map((s) => [s.track, s.title])).toEqual([[1, "Polyphemus"], [2, "Survive"]]);
|
||||
expect(cyc.songs[0]!.lyrics).toMatchObject({ timed: true });
|
||||
expect(cyc.songs[0]!.lyrics!.url.endsWith(".lrc")).toBe(true);
|
||||
expect(lib.sagas[2]!.songs[0]!.lyrics).toMatchObject({ timed: false });
|
||||
expect(lib.sagas[0]!.cover).toBeNull();
|
||||
expect(lib.sagas[0]!.songs[0]!.lyrics).toBeNull();
|
||||
});
|
||||
test("a missing folder gives an empty library", () => {
|
||||
expect(scanLibrary(join(dir, "nope")).sagas).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/content routes", () => {
|
||||
let withContent: Server<undefined>;
|
||||
let without: Server<undefined>;
|
||||
beforeAll(() => {
|
||||
withContent = startServer(0, { contentDir: join(dir) });
|
||||
without = startServer(0, { contentDir: undefined });
|
||||
});
|
||||
afterAll(() => { withContent.stop(true); without.stop(true); });
|
||||
const url = (s: Server<undefined>, p: string) => `http://localhost:${s.port}${p}`;
|
||||
|
||||
test("library.json lists the scanned library", async () => {
|
||||
const res = await fetch(url(withContent, "/content/library.json"));
|
||||
expect(res.status).toBe(200);
|
||||
const lib = await res.json();
|
||||
expect(lib.sagas).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("files are served, and a range request returns 206", async () => {
|
||||
const lib = scanLibrary(dir);
|
||||
const audio = lib.sagas[1]!.songs[0]!.audio;
|
||||
const full = await fetch(url(withContent, audio));
|
||||
expect(full.status).toBe(200);
|
||||
expect(full.headers.get("content-type")).toBe("audio/flac");
|
||||
expect(await full.text()).toBe("0123456789");
|
||||
const part = await fetch(url(withContent, audio), { headers: { Range: "bytes=2-5" } });
|
||||
expect(part.status).toBe(206);
|
||||
expect(part.headers.get("content-range")).toBe("bytes 2-5/10");
|
||||
expect(await part.text()).toBe("2345");
|
||||
});
|
||||
|
||||
test("paths escaping the content folder are refused", async () => {
|
||||
for (const p of ["/content/files/..%2F..%2Fetc%2Fpasswd", "/content/files/Songs%20and%20Lyrics/..%2F..%2F..%2Fetc%2Fhosts"]) {
|
||||
const res = await fetch(url(withContent, p));
|
||||
expect(res.status).toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
test("homer books are served as JSON", async () => {
|
||||
const res = await fetch(url(withContent, "/content/homer/odyssey/2.json"));
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toMatchObject({ book: 2, summary: "ASSEMBLY." });
|
||||
expect((await fetch(url(withContent, "/content/homer/odyssey/99.json"))).status).toBe(404);
|
||||
expect((await fetch(url(withContent, "/content/homer/iliad/1.json"))).status).toBe(404);
|
||||
});
|
||||
|
||||
test("without a content folder every /content route is 404 and the site still works", async () => {
|
||||
expect((await fetch(url(without, "/content/library.json"))).status).toBe(404);
|
||||
expect((await fetch(url(without, "/content/homer/odyssey/1.json"))).status).toBe(404);
|
||||
expect((await fetch(url(without, "/"))).status).toBe(200);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user