Collect discovered video metadata and recommend videos from listening history
This commit is contained in:
143
frontend/app.js
143
frontend/app.js
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user