Files
ytplayer/server/media-meta.js

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