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); } }); }