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.
|
||||
- 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.
|
||||
|
||||
@@ -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');
|
||||
|
||||
// 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('/', (_req, res) => {
|
||||
res.type('text/plain').send('IG Japanese Quizzer backend is running. See /api/entries.');
|
||||
});
|
||||
}
|
||||
|
||||
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}`);
|
||||
|
||||
Reference in New Issue
Block a user