67 lines
4.4 KiB
JavaScript
67 lines
4.4 KiB
JavaScript
import { validateMeta, mergeMissing } from './media-meta-core.js';
|
|
|
|
async function readBody(request) {
|
|
if (Number(request.headers.get('content-length')) > 8192) throw new Error('Metadata is too large');
|
|
const reader = request.body?.getReader(); if (!reader) throw new Error('Metadata is required');
|
|
const chunks = []; let size = 0;
|
|
try {
|
|
for (;;) {
|
|
const { done, value } = await reader.read(); if (done) break;
|
|
size += value.byteLength; if (size > 8192) throw new Error('Metadata is too large'); chunks.push(value);
|
|
}
|
|
} finally { await reader.cancel().catch(() => {}); }
|
|
const bytes = new Uint8Array(size); let offset = 0;
|
|
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
|
|
return JSON.parse(new TextDecoder().decode(bytes));
|
|
}
|
|
export function createLimiter({ now = Date.now, perClient = 20, global = 100 } = {}) {
|
|
const clients = new Map(); let windowStart = now(), count = 0;
|
|
return key => {
|
|
const time = now();
|
|
if (time - windowStart >= 60000) { clients.clear(); count = 0; windowStart = time; }
|
|
if (count >= global || (clients.get(key) || 0) >= perClient || (!clients.has(key) && clients.size >= 2000)) return false;
|
|
clients.set(key, (clients.get(key) || 0) + 1); count++; return true;
|
|
};
|
|
}
|
|
export function registerMediaMetaRoutes(app, { db, catalog, media, now = Date.now, limiter = createLimiter({ now }), maxPending = 32, log = console }) {
|
|
const pending = new Map();
|
|
app.post('/api/media/:id/meta', async c => {
|
|
const who = (c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || 'local').split(',')[0].trim();
|
|
if (!limiter(who)) return c.json({ ok: false, error: 'Too many metadata reports' }, 429, { 'Retry-After': '60' });
|
|
let card;
|
|
try { card = validateMeta(c.req.param('id'), await readBody(c.req.raw)); }
|
|
catch (error) { return c.json({ ok: false, error: error.message }, 400); }
|
|
try {
|
|
const old = await db.getMedia(card.id);
|
|
const catalogRow = (await db.db.execute({ sql: 'SELECT card FROM video_meta WHERE id=?', args: [card.id] })).rows[0];
|
|
let richer = {}; try { richer = JSON.parse(catalogRow?.card || '{}'); } catch { /* absent */ }
|
|
const hint = mergeMissing(richer, card);
|
|
// SQL merges against the row at write time, so a concurrent extractor
|
|
// update cannot be overwritten by a stale device read.
|
|
await db.db.execute({ sql: `INSERT INTO media_cache (video_id,meta,duration,status,auto,created_at,updated_at,last_access)
|
|
VALUES (?,?,?,'failed',1,?,?,?) ON CONFLICT(video_id) DO UPDATE SET
|
|
meta=json_patch(media_cache.meta, (SELECT COALESCE(json_group_object(key,value),'{}') FROM json_each(excluded.meta)
|
|
WHERE (json_extract(media_cache.meta,'$.'||key) IS NULL OR json_extract(media_cache.meta,'$.'||key) IN ('',0)
|
|
OR (key='title' AND json_extract(media_cache.meta,'$.title') IN ('(untitled)',media_cache.video_id)))
|
|
AND NOT (key='channel' AND COALESCE(NULLIF(json_extract(media_cache.meta,'$.uploader'),''),NULLIF(json_extract(media_cache.meta,'$.artist'),''),'')!=''))),
|
|
duration=CASE WHEN media_cache.duration<=0 THEN excluded.duration ELSE media_cache.duration END`,
|
|
args: [card.id, JSON.stringify(Object.fromEntries(Object.keys(card).map(key => [key, hint[key]]))), hint.duration || 0, now(), now(), now()] });
|
|
// Keep the recommendation/thumbnail catalog in step with the media row.
|
|
const row = await db.getMedia(card.id), saved = JSON.parse(row.meta);
|
|
await catalog.ingest([{ ...saved, channel: saved.channel || saved.uploader || saved.artist || '', id: card.id }], 'device-play', { fillMissing: true });
|
|
let cache = 'queued';
|
|
if (await media.getReady(card.id)) cache = 'ready';
|
|
else if (!media.available()) cache = 'unavailable';
|
|
else if (!pending.has(card.id)) {
|
|
if (pending.size >= maxPending) cache = 'busy';
|
|
else {
|
|
const work = media.ensureCached(card.id, { priority: 1, auto: true });
|
|
pending.set(card.id, work);
|
|
work.catch(error => log.warn?.(`[media-meta] ${card.id}: ${error.message}`)).finally(() => pending.delete(card.id));
|
|
}
|
|
}
|
|
return c.json({ ok: true, known: !!old, cache }, cache === 'ready' ? 200 : 202, { 'Cache-Control': 'no-store' });
|
|
} catch (error) { log.warn?.('[media-meta]', error.message); return c.json({ ok: false, error: 'Could not register video metadata' }, 500); }
|
|
});
|
|
}
|