Collect discovered video metadata and recommend videos from listening history

This commit is contained in:
Jonathan Sykes
2026-10-03 07:16:51 +08:00
parent 343eb53d89
commit 5154ef5f75
16 changed files with 963 additions and 24 deletions

View File

@@ -84,10 +84,13 @@ function saveDataToStorage(jsonStr) {
const d = JSON.parse(jsonStr);
fetch('/api/user/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: d.profile?.name ? profileHeaders(d.profile.name, { 'Content-Type': 'application/json' }) : { 'Content-Type': 'application/json' },
body: JSON.stringify({
fingerprint: window.getFingerprint ? window.getFingerprint() : 'unknown',
playlists: d.playlists || [],
videos: [...(d.history || []), ...(d.queue || [])].slice(0, 500),
stats: d.stats || null,
profileName: d.profile?.name || '',
appVersion: APP_VERSION,
}),
}).catch(() => {});
@@ -245,7 +248,7 @@ const SearchLibrary = (() => {
const list = results.slice(0, PER_QUERY);
const bytes = JSON.stringify(list).length;
index[key] = { query: q, count: list.length, ts: ts || Date.now(), bytes, thumb: (list[0] || {}).thumbnail || '' };
idbPut({ key, query: q, results: list, ts: index[key].ts });
idbPut({ key, query: q, results: list, ts: index[key].ts }).then(() => collectSearchMetadata(q, list));
const keys = Object.keys(index);
if (keys.length > MAX_QUERIES) {
const drop = keys.sort((a, b) => (index[a].ts || 0) - (index[b].ts || 0)).slice(0, keys.length - MAX_QUERIES);
@@ -5539,6 +5542,123 @@ const Wave = (() => {
return { load, draw, refresh };
})();
// Retry searches discovered from IndexedDB, including searches made offline.
// Store query keys rather than duplicating the full result sets in localStorage.
const CatalogSync = (() => {
const key = 'ytpCatalogPending';
let pending = new Set();
try { pending = new Set(JSON.parse(localStorage.getItem(key) || '[]')); } catch { /* empty */ }
const busy = new Set();
const save = () => { try { localStorage.setItem(key, JSON.stringify([...pending])); } catch { /* blocked */ } };
async function send(q, cards) {
if (!WEB || busy.has(q)) return;
pending.add(q); save();
busy.add(q);
try {
await webFetch('/api/catalog/collect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cards: cards.slice(0, 500) }) });
pending.delete(q); save();
} catch { /* next online event / timer / launch retries from IndexedDB */ }
finally { busy.delete(q); }
}
async function flush() {
if (!WEB || !navigator.onLine) return;
for (const q of [...pending].slice(0, 20)) {
const rec = await SearchLibrary.get(q);
if (rec) await send(q, rec.results);
else { pending.delete(q); save(); }
}
}
window.addEventListener('online', flush);
if (WEB) setInterval(flush, 60000);
return { send, flush };
})();
function collectSearchMetadata(q, cards) { if (WEB) CatalogSync.send(q, cards); }
const Recommendations = (() => {
let cards = [], fetchedAt = 0, loading = false, error = false, actor = '', queuedRefresh = false;
let listHost = null;
const deviceFingerprint = () => window.getFingerprint ? window.getFingerprint() : 'unknown';
const fingerprint = () => data.profile?.name ? 'profile:' + data.profile.name.toLowerCase() : 'device:' + deviceFingerprint();
function load() {
const fp = fingerprint();
if (fp === actor) return;
actor = fp; cards = []; fetchedAt = 0;
try {
const saved = JSON.parse(localStorage.getItem('ytpRecommendations:' + fp) || 'null');
if (saved && Array.isArray(saved.cards)) cards = saved.cards;
} catch { /* no saved recommendations */ }
}
function paint(host) {
if (!host) return;
host.replaceChildren();
const heading = document.createElement('div'); heading.className = 'rec-heading';
const copy = document.createElement('div');
const title = document.createElement('h2'); title.textContent = 'Recommended for you';
const sub = document.createElement('p'); sub.textContent = error && cards.length ? 'Saved picks · reconnect to refresh' : 'Familiar favorites and something new, inspired by your listening.';
copy.append(title, sub);
const refresh = document.createElement('button'); refresh.type = 'button'; refresh.className = 'rec-refresh';
refresh.textContent = loading ? 'Updating…' : 'Refresh'; refresh.disabled = loading;
refresh.onclick = () => update(true);
heading.append(copy, refresh); host.appendChild(heading);
if (!cards.length) {
const empty = document.createElement('p'); empty.className = 'rec-empty'; empty.setAttribute('role', 'status');
empty.textContent = loading ? 'Finding your next listen…' : error ? 'Recommendations are unavailable. You can still search or play your playlists.' : 'Search or play a few videos to start discovering recommendations here.';
host.appendChild(empty); return;
}
const grid = document.createElement('div'); grid.className = 'rec-grid';
cards.forEach((v, i) => {
const item = document.createElement('article'); item.className = 'rec-item';
const play = document.createElement('button'); play.type = 'button'; play.className = 'rec-play';
play.setAttribute('aria-label', 'Play ' + v.title);
const cover = document.createElement('span'); cover.className = 'rec-cover';
const img = document.createElement('img'); img.src = v.thumbnail || ''; img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; cover.appendChild(img);
const title = document.createElement('span'); title.className = 'rec-title'; title.textContent = v.title;
const channel = document.createElement('span'); channel.className = 'rec-channel'; channel.textContent = v.channel || '';
play.append(cover, title, channel); play.onclick = () => { setHomeOverPlayer(false); playFromList(cards.slice(), i, 'recommendations'); };
const reason = document.createElement('div'); reason.className = 'rec-reason'; reason.textContent = v.reason || 'Recommended for you';
const actions = document.createElement('div'); actions.className = 'rec-actions';
const queue = document.createElement('button'); queue.type = 'button'; queue.textContent = '+ Queue'; queue.setAttribute('aria-label', 'Queue ' + v.title); queue.onclick = () => addToQueue(v);
const more = document.createElement('button'); more.type = 'button'; more.textContent = '•••'; more.setAttribute('aria-label', 'More actions for ' + v.title); more.onclick = () => openCardMenu(v);
actions.append(queue, more); item.append(play, reason, actions); grid.appendChild(item);
});
host.appendChild(grid);
}
function repaint() {
if (!WEB) return;
const home = $('homeRecommendations');
if (home) { home.classList.remove('hidden'); paint(home); }
els.playerPane?.classList.add('has-recommendations');
if (listHost?.isConnected) paint(listHost);
requestAnimationFrame(updateBottomChrome);
}
async function update(force = false) {
load();
if (!WEB) return;
if (loading) { if (force) queuedRefresh = true; return; }
if (!force && Date.now() - fetchedAt < 300000) return;
const fp = actor; loading = true; error = false; repaint();
try {
const name = data.profile?.name || '';
const result = await webFetch('/api/recommendations?fp=' + encodeURIComponent(deviceFingerprint()) + (name ? '&profile=' + encodeURIComponent(name) : ''), { headers: name ? profileHeaders(name) : {} });
if (fp !== fingerprint()) return;
if (!result.ok || !Array.isArray(result.results)) throw new Error('invalid recommendations');
cards = result.results;
try { localStorage.setItem('ytpRecommendations:' + fp, JSON.stringify({ cards })); } catch { /* full */ }
} catch { error = true; }
finally {
loading = false; fetchedAt = fp === fingerprint() ? Date.now() : 0; repaint();
if (queuedRefresh || fp !== fingerprint()) { queuedRefresh = false; update(true); }
}
}
function home() { if (WEB) { load(); repaint(); update(); } }
function inList() {
if (!WEB) return;
listHost = document.createElement('section'); listHost.className = 'recommendations'; listHost.setAttribute('aria-label', 'Recommended videos');
els.cards.appendChild(listHost); load(); paint(listHost); update();
}
return { home, inList, update };
})();
// ============================================================================
// Listening stats — tracker (data.stats, see frontend/stats-core.js)
// ============================================================================
@@ -8060,6 +8180,7 @@ function renderHomePlaylists() {
const hero = document.querySelector('#playerPlaceholder .hero');
const grid = document.getElementById('homePlGrid');
if (!wrap || !hero || !grid) return;
Recommendations.home();
const lists = data.playlists || [];
wrap.classList.toggle('hidden', !lists.length);
@@ -8206,7 +8327,7 @@ function renderList() {
resetListHeaderExtras();
{ const ls = $('listSub'); if (ls) ls.textContent = ''; const ps = $('p2pStrip'); if (ps) ps.classList.add('hidden'); }
if (view.type !== 'search') { const sm = $('searchMeta'); if (sm) sm.classList.add('hidden'); }
setListPaneHidden(view.type === 'search' && !searchResults.length && !searchAttempted);
setListPaneHidden(view.type === 'search' && !searchResults.length && !searchAttempted && !els.placeholder.classList.contains('hidden'));
if (view.type === 'settings') { renderSettings(); return; }
if (view.type === 'saved') { renderSaved(); return; }
if (view.type === 'p2p') { renderP2pList(); return; }
@@ -8237,6 +8358,10 @@ function renderList() {
resetListHeaderExtras();
els.listActions.innerHTML = '';
if (view.type === 'search' && WEB && els.placeholder.classList.contains('hidden')) {
Recommendations.inList();
}
if (view.type === 'search') {
els.listTitle.textContent = 'Search results';
} else if (view.type === 'history') {
@@ -10434,6 +10559,7 @@ function wireUI() {
showSearchMeta(q, instant.ts);
// Even on a fresh copy, make sure thumbnails are in the SW cache.
warmSearchThumbs(instant.results);
collectSearchMetadata(q, instant.results);
if (SearchLibrary.isFresh(instant.ts)) { scrollListIntoViewPortrait(); return; }
} else {
render();
@@ -10565,7 +10691,7 @@ function wireUI() {
document.querySelectorAll('.nav-item').forEach((b) => {
if (!b.dataset.view) return; // e.g. 📱 Remote opens a panel, not a view
b.addEventListener('click', () => { view = { type: b.dataset.view }; render(); });
b.addEventListener('click', () => { view = { type: b.dataset.view }; if (b.dataset.view === 'search') setHomeOverPlayer(true); render(); });
});
// Bottom-nav (portrait PWA) — same view switching as sidebar nav. Search
@@ -11941,12 +12067,17 @@ async function boot() {
if (WEB) {
fetch('/api/user/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: data.profile?.name ? profileHeaders(data.profile.name, { 'Content-Type': 'application/json' }) : { 'Content-Type': 'application/json' },
body: JSON.stringify({
fingerprint: window.getFingerprint ? window.getFingerprint() : 'unknown',
appVersion: APP_VERSION,
playlists: data.playlists || [],
videos: [...(data.history || []), ...(data.queue || [])].slice(0, 500),
stats: data.stats || null,
profileName: data.profile?.name || '',
}),
}).catch(() => {});
}).then(() => Recommendations.update(true)).catch(() => {});
CatalogSync.flush();
}
}