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