66 lines
4.0 KiB
JavaScript
66 lines
4.0 KiB
JavaScript
// Related discovery is bounded and independent of the deep search collector.
|
||
export function relatedQueries({ title = '', channel = '' }) {
|
||
title = String(title).replace(/\([^)]*(?:official|lyrics?|music video)[^)]*\)|\[[^\]]*(?:official|lyrics?|music video)[^\]]*\]/gi, '').replace(/\s+/g, ' ').trim().slice(0, 300);
|
||
channel = String(channel).trim().slice(0, 120);
|
||
return [...new Set([title && channel ? `${title} ${channel}` : '', title, channel].filter(Boolean))];
|
||
}
|
||
export function selectRelated(cards, id, limit = 8) {
|
||
const seen = new Set([id]);
|
||
return (Array.isArray(cards) ? cards : []).filter(card => {
|
||
if (!card || typeof card.id !== 'string' || !card.title || seen.has(card.id)) return false;
|
||
seen.add(card.id); return true;
|
||
}).slice(0, limit);
|
||
}
|
||
export function createRelatedService({ next, search, local, collect = async () => {}, decorate = async cards => cards, now = Date.now, budgetMs = 18000 }) {
|
||
const cache = new Map(), pending = new Map();
|
||
async function load(seed, { refresh = false } = {}) {
|
||
const key = JSON.stringify([seed.id, seed.title, seed.channel]);
|
||
const cached = cache.get(key);
|
||
if (!refresh && cached && now() - cached.at < cached.ttl) return cached.result;
|
||
if (pending.has(key)) return pending.get(key);
|
||
const task = (async () => {
|
||
const deadline = now() + budgetMs, details = [];
|
||
let results = [], source = 'youtube';
|
||
try { results = selectRelated(await next(seed.id, Math.min(4000, budgetMs)), seed.id); }
|
||
catch (error) { details.push(String(error.message || error).slice(0, 300)); }
|
||
const queries = relatedQueries(seed);
|
||
for (const query of queries) {
|
||
if (results.length || now() >= deadline) break;
|
||
try { results = selectRelated(await search(query, Math.max(1, deadline - now())), seed.id); if (results.length) source = 'search'; }
|
||
catch (error) { details.push(String(error.message || error).slice(0, 300)); }
|
||
}
|
||
if (!results.length) {
|
||
source = 'catalog';
|
||
// Local results still work when YouTube blocks or times out. Broad queries
|
||
// avoid requiring every word of a long video title to be in another title.
|
||
const localQueries = [...new Set([seed.channel, seed.title?.split(/\s+/).slice(0, 3).join(' '), ...queries].filter(Boolean))];
|
||
for (const query of localQueries) {
|
||
try { results = selectRelated(await local(query), seed.id); } catch { /* try another local query */ }
|
||
if (results.length) break;
|
||
}
|
||
}
|
||
if (!results.length) return { ok: false, error: 'Related videos aren’t available right now. Please try again.', detail: details.join('\n') };
|
||
// Metadata/thumbnail persistence must not discard otherwise playable cards.
|
||
try { await collect(results); } catch { /* discovery remains usable */ }
|
||
try { results = await decorate(results); } catch { /* remote thumbnails remain */ }
|
||
const result = { ok: true, results, source, ...(source === 'catalog' ? { message: 'YouTube is unavailable. Showing related videos already known to this server.' } : {}) };
|
||
if (cache.size >= 500) cache.delete(cache.keys().next().value);
|
||
cache.set(key, { result, at: now(), ttl: source === 'catalog' ? 60000 : 600000 });
|
||
return result;
|
||
})().finally(() => pending.delete(key));
|
||
pending.set(key, task); return task;
|
||
}
|
||
return { load };
|
||
}
|
||
export function registerRelatedRoute(app, service) {
|
||
app.get('/api/related', async c => {
|
||
const id = (c.req.query('videoId') || '').trim();
|
||
if (!/^[\w-]{11}$/.test(id)) return c.json({ ok: false, error: 'Invalid video id.' }, 400);
|
||
const seed = { id, title: (c.req.query('title') || '').trim().slice(0, 300), channel: (c.req.query('channel') || '').trim().slice(0, 120) };
|
||
try {
|
||
const result = await service.load(seed, { refresh: c.req.query('refresh') === '1' });
|
||
return c.json(result, result.ok ? 200 : 503);
|
||
} catch { return c.json({ ok: false, error: 'Related videos aren’t available right now. Please try again.' }, 503); }
|
||
});
|
||
}
|