Update README and server code to support external media hosting via MEDIA_BASE_URL
This commit is contained in:
@@ -30,7 +30,8 @@ data/<POST_ID>/<FILENAME>.mp4
|
|||||||
- Base filenames must match. Extra sidecars (`.raw.txt`, `.mp4.json`, images) are ignored.
|
- 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).
|
- 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.
|
- `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
|
## API
|
||||||
- `GET /api/entries` → list of entries `{ id, title, mode, type, counts, video_url }`, sorted by title.
|
- `GET /api/entries` → list of entries `{ id, title, mode, type, counts, video_url }`, sorted by title.
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
const DEFAULT_DATA_ROOT = path.resolve(__dirname, '..', '..', 'data');
|
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 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
|
const metaSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -127,7 +129,8 @@ function buildVideoUrl(id: string) {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((segment) => encodeURIComponent(segment))
|
.map((segment) => encodeURIComponent(segment))
|
||||||
.join('/');
|
.join('/');
|
||||||
return `/data/${encoded}.mp4`;
|
const base = MEDIA_BASE_URL.replace(/\/+$/, '');
|
||||||
|
return `${base}/${encoded}.mp4`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeCounts(items: EntryData['items'], quiz: EntryData['quiz']) {
|
function computeCounts(items: EntryData['items'], quiz: EntryData['quiz']) {
|
||||||
@@ -185,27 +188,23 @@ async function loadEntries() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mp4Paths = await glob('**/*.mp4', { cwd: DATA_ROOT, absolute: true });
|
const jsonPaths = await glob('**/*.json', { cwd: DATA_ROOT, absolute: true });
|
||||||
for (const mp4Path of mp4Paths) {
|
for (const quizJsonPath of jsonPaths) {
|
||||||
const resolvedMp4 = path.resolve(mp4Path);
|
if (quizJsonPath.endsWith('.mp4.json')) {
|
||||||
if (!ensureWithinDataRoot(resolvedMp4)) {
|
// skip instagram metadata files
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dir = path.dirname(resolvedMp4);
|
const resolvedJson = path.resolve(quizJsonPath);
|
||||||
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);
|
|
||||||
if (!ensureWithinDataRoot(resolvedJson)) {
|
if (!ensureWithinDataRoot(resolvedJson)) {
|
||||||
continue;
|
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'];
|
let igMeta: EntryRecord['igMeta'];
|
||||||
const resolvedMp4Meta = path.resolve(mp4MetaPath);
|
const resolvedMp4Meta = path.resolve(mp4MetaPath);
|
||||||
if (await fileExists(resolvedMp4Meta)) {
|
if (await fileExists(resolvedMp4Meta)) {
|
||||||
@@ -234,8 +233,8 @@ async function loadEntries() {
|
|||||||
parsed = entrySchema.parse({});
|
parsed = entrySchema.parse({});
|
||||||
}
|
}
|
||||||
|
|
||||||
const relative = path.relative(DATA_ROOT, resolvedMp4);
|
const relative = path.relative(DATA_ROOT, resolvedJson);
|
||||||
const id = toPosixId(relative.replace(/\.mp4$/i, ''));
|
const id = toPosixId(relative.replace(/\.json$/i, ''));
|
||||||
const title = parsed.meta?.title_en?.trim() || baseName;
|
const title = parsed.meta?.title_en?.trim() || baseName;
|
||||||
const video_url = buildVideoUrl(id);
|
const video_url = buildVideoUrl(id);
|
||||||
const counts = computeCounts(parsed.items || { grammar: [], vocab: [], conversation: [], key_phrases: [] }, parsed.quiz || []);
|
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.disable('x-powered-by');
|
||||||
|
|
||||||
|
// 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.use('/data', express.static(DATA_ROOT));
|
||||||
|
}
|
||||||
app.get('/', (_req, res) => {
|
|
||||||
res.type('text/plain').send('IG Japanese Quizzer backend is running. See /api/entries.');
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/entries', (_req, res) => {
|
app.get('/api/entries', (_req, res) => {
|
||||||
const entries = Array.from(entryIndex.values())
|
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, () => {
|
app.listen(port, () => {
|
||||||
console.log(`Server listening on http://localhost:${port}`);
|
console.log(`Server listening on http://localhost:${port}`);
|
||||||
console.log(`Data root: ${DATA_ROOT}`);
|
console.log(`Data root: ${DATA_ROOT}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user