From 363a070501540754030601fb1f623b1988a7405f Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Thu, 8 Jan 2026 04:42:02 +0100 Subject: [PATCH] Update README and server code to support external media hosting via MEDIA_BASE_URL --- README.md | 3 ++- server/src/index.ts | 53 ++++++++++++++++++++++++++------------------- 2 files changed, 33 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index b5ea13f..af6c6cd 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,8 @@ data//.mp4 - Base filenames must match. Extra sidecars (`.raw.txt`, `.mp4.json`, images) are ignored. - The JSON structure is tolerant but expects keys: `meta`, `items`, `quiz`, `ui_hints` (additional fields are ignored). - `entry_id` is the path from `data/` to the mp4 **without** the `.mp4` extension (e.g., `C1abc/12345`). It is URL-encoded in routes/query params. -- Videos are served statically at `/data/...` by the backend; JSON is only accessible through the API. +- Videos are served statically at `/data/...` by the backend by default; JSON is only accessible through the API. +- To host media elsewhere (e.g., a CDN), set `MEDIA_BASE_URL` to the base URL of your `data` directory (e.g., `https://www.victorgiers.com/japanischvideos/data`). Video links will point there; JSON files still need to be present locally for scanning. ## API - `GET /api/entries` → list of entries `{ id, title, mode, type, counts, video_url }`, sorted by title. diff --git a/server/src/index.ts b/server/src/index.ts index 1f4b81b..716d238 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -6,6 +6,8 @@ import { z } from 'zod'; const DEFAULT_DATA_ROOT = path.resolve(__dirname, '..', '..', 'data'); const DATA_ROOT = process.env.DATA_ROOT ? path.resolve(process.env.DATA_ROOT) : DEFAULT_DATA_ROOT; +const MEDIA_BASE_URL = process.env.MEDIA_BASE_URL || '/data'; +const CLIENT_DIST = path.resolve(__dirname, '..', '..', 'client', 'dist'); const metaSchema = z .object({ @@ -127,7 +129,8 @@ function buildVideoUrl(id: string) { .filter(Boolean) .map((segment) => encodeURIComponent(segment)) .join('/'); - return `/data/${encoded}.mp4`; + const base = MEDIA_BASE_URL.replace(/\/+$/, ''); + return `${base}/${encoded}.mp4`; } function computeCounts(items: EntryData['items'], quiz: EntryData['quiz']) { @@ -185,27 +188,23 @@ async function loadEntries() { return; } - const mp4Paths = await glob('**/*.mp4', { cwd: DATA_ROOT, absolute: true }); - for (const mp4Path of mp4Paths) { - const resolvedMp4 = path.resolve(mp4Path); - if (!ensureWithinDataRoot(resolvedMp4)) { + const jsonPaths = await glob('**/*.json', { cwd: DATA_ROOT, absolute: true }); + for (const quizJsonPath of jsonPaths) { + if (quizJsonPath.endsWith('.mp4.json')) { + // skip instagram metadata files continue; } - const dir = path.dirname(resolvedMp4); - const baseName = path.basename(resolvedMp4, '.mp4'); - const jsonPath = path.join(dir, `${baseName}.json`); - const mp4MetaPath = path.join(dir, `${baseName}.mp4.json`); - - if (!(await fileExists(jsonPath))) { - continue; - } - - const resolvedJson = path.resolve(jsonPath); + const resolvedJson = path.resolve(quizJsonPath); if (!ensureWithinDataRoot(resolvedJson)) { continue; } + const dir = path.dirname(resolvedJson); + const baseName = path.basename(resolvedJson, '.json'); + const resolvedMp4 = path.join(dir, `${baseName}.mp4`); + const mp4MetaPath = path.join(dir, `${baseName}.mp4.json`); + let igMeta: EntryRecord['igMeta']; const resolvedMp4Meta = path.resolve(mp4MetaPath); if (await fileExists(resolvedMp4Meta)) { @@ -234,8 +233,8 @@ async function loadEntries() { parsed = entrySchema.parse({}); } - const relative = path.relative(DATA_ROOT, resolvedMp4); - const id = toPosixId(relative.replace(/\.mp4$/i, '')); + const relative = path.relative(DATA_ROOT, resolvedJson); + const id = toPosixId(relative.replace(/\.json$/i, '')); const title = parsed.meta?.title_en?.trim() || baseName; const video_url = buildVideoUrl(id); const counts = computeCounts(parsed.items || { grammar: [], vocab: [], conversation: [], key_phrases: [] }, parsed.quiz || []); @@ -280,11 +279,10 @@ async function main() { app.disable('x-powered-by'); - app.use('/data', express.static(DATA_ROOT)); - - app.get('/', (_req, res) => { - res.type('text/plain').send('IG Japanese Quizzer backend is running. See /api/entries.'); - }); + // Serve local data only when MEDIA_BASE_URL points to the local /data path + if (MEDIA_BASE_URL === '/data') { + app.use('/data', express.static(DATA_ROOT)); + } app.get('/api/entries', (_req, res) => { const entries = Array.from(entryIndex.values()) @@ -366,6 +364,17 @@ async function main() { } }); + const hasClient = await fileExists(path.join(CLIENT_DIST, 'index.html')); + if (hasClient) { + app.use(express.static(CLIENT_DIST)); + app.get('*', (req, res, next) => { + if (req.path.startsWith('/api')) return next(); + res.sendFile(path.join(CLIENT_DIST, 'index.html')); + }); + } else { + console.warn('Client dist not found; only API will be served.'); + } + app.listen(port, () => { console.log(`Server listening on http://localhost:${port}`); console.log(`Data root: ${DATA_ROOT}`);