diff --git a/CLAUDE.md b/CLAUDE.md index 19aa69f..f745501 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +56,14 @@ JSON shapes mirror the Tauri Rust bridge exactly — don't change one side alone extras, `.mode-name`) are in the shared markup and hidden by default. - Now-playing tiles are `[svg.ti][span.tl]`; change state text with `setTile(btn, label)`, never `textContent`. +## Saved searches + caching notes +- `SearchLibrary` (app.js, localStorage `ytpSearchLibrary`, ≤150 queries × 50 results incl. thumbnail URLs) keeps + every search. The search box's recent list and its "Clear this list" only touch `ytpRecentSearches`; results are + deleted ONLY in Settings → Saved searches (select + delete, or clear all). Thumbnails are warmed into the SW + `ytplayer-thumbs` cache (`THUMB_CACHE_MAX` 2500). Browse view: `{type:'savedSearches'}`. +- `/api/search`: 10 min fresh cache, then stale-while-revalidate up to 6 h (answers instantly, refreshes in the + background); identical concurrent searches share one fetch (`fetchSearch`). `/fonts` and `/icons` are cached 30 days. + ## Server media cache (`server/media-cache.js`) - Every played (`/api/streams`, LOW priority, ≤ `MEDIA_AUTO_MAX_SECONDS`, default 3 h) or saved (`/api/download`, HIGH, ≤ 3 h) video gets ONE copy: `$MEDIA_DIR/..mp4` diff --git a/frontend/app.js b/frontend/app.js index f859bee..2f1e14e 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -135,31 +135,89 @@ const RecentSearches = (() => { list.unshift(q); const trimmed = list.slice(0, MAX); save(trimmed); - pruneResults(trimmed); }, remove(q) { const list = load().filter((x) => x.toLowerCase() !== (q || '').toLowerCase()); save(list); - pruneResults(list); }, - clear() { save([]); saveResults({}); }, + clear() { save([]); }, // saved results stay in SearchLibrary (cleared only from Settings) // Cache the just-fetched result set for `q` (called on every successful search). - cacheResults(q, results) { - q = (q || '').trim(); - if (!q || !Array.isArray(results) || !results.length) return; - const r = loadResults(); - r[norm(q)] = { query: q, results: results.slice(0, RESULTS_PER_QUERY_MAX), ts: Date.now() }; - saveResults(r); - pruneResults(load()); - }, + cacheResults(q, results) { SearchLibrary.put(q, results); }, // Cached {query, results, ts} for `q`, or null. getResults(q) { - const hit = loadResults()[norm(q)]; - return hit && Array.isArray(hit.results) && hit.results.length ? hit : null; + return SearchLibrary.get(q); }, // Normalized queries that have a cached result set — one localStorage // read for the whole dropdown, rather than one per row. - cachedQueries() { return new Set(Object.keys(loadResults())); }, + cachedQueries() { return new Set(SearchLibrary.list().map((x) => x.key)); }, + }; +})(); + + +// ============================================================================ +// Search library — every result set (with thumbnail URLs) is kept on this device +// ============================================================================ +// Clearing the search box or the recent-searches dropdown never deletes these; +// they are removed only from Settings → Saved searches (all, or a selection). +// The thumbnails themselves live in the service worker's `ytplayer-thumbs` +// cache (warmed below), so a saved search renders with its pictures offline. +const SearchLibrary = (() => { + const KEY = 'ytpSearchLibrary'; + const LEGACY = 'ytpRecentSearchResults'; + const MAX_QUERIES = 150; + const PER_QUERY = 50; + const norm = (q) => String(q || '').trim().toLowerCase(); + let mem = null; + function read() { + if (mem) return mem; + let o = {}; + try { o = JSON.parse(localStorage.getItem(KEY) || '{}') || {}; } catch { o = {}; } + // One-time import of the old per-recent-search cache. + try { + const old = JSON.parse(localStorage.getItem(LEGACY) || 'null'); + if (old && typeof old === 'object') { + for (const [k, v] of Object.entries(old)) if (v && Array.isArray(v.results) && !o[k]) o[k] = v; + localStorage.removeItem(LEGACY); + } + } catch { /* ignore */ } + mem = o; + return mem; + } + function write() { + const o = read(); + for (let attempt = 0; attempt < 4; attempt++) { + try { localStorage.setItem(KEY, JSON.stringify(o)); return true; } catch { + // Quota: drop the oldest fifth and retry. + const keys = Object.keys(o).sort((a, b) => (o[a].ts || 0) - (o[b].ts || 0)); + keys.slice(0, Math.max(1, Math.ceil(keys.length / 5))).forEach((k) => delete o[k]); + } + } + return false; + } + return { + put(q, results) { + q = String(q || '').trim(); + if (!q || !Array.isArray(results) || !results.length) return; + const o = read(); + o[norm(q)] = { query: q, results: results.slice(0, PER_QUERY), ts: Date.now() }; + const keys = Object.keys(o); + if (keys.length > MAX_QUERIES) { + keys.sort((a, b) => (o[a].ts || 0) - (o[b].ts || 0)).slice(0, keys.length - MAX_QUERIES).forEach((k) => delete o[k]); + } + write(); + }, + get(q) { const h = read()[norm(q)]; return h && Array.isArray(h.results) && h.results.length ? h : null; }, + has(q) { return !!read()[norm(q)]; }, + // Newest first: [{ query, count, ts, thumb }] + list() { + const o = read(); + return Object.keys(o).map((k) => ({ key: k, query: o[k].query || k, count: (o[k].results || []).length, ts: o[k].ts || 0, thumb: ((o[k].results || [])[0] || {}).thumbnail || '' })) + .sort((a, b) => b.ts - a.ts); + }, + remove(keys) { const o = read(); (keys || []).forEach((k) => delete o[k]); write(); }, + clear() { mem = {}; try { localStorage.removeItem(KEY); } catch { /* blocked */ } }, + bytes() { try { return (localStorage.getItem(KEY) || '').length; } catch { return 0; } }, + count() { return Object.keys(read()).length; }, }; })(); @@ -1242,6 +1300,14 @@ function startCacheResyncWatch() { // is only cached once something has actually requested it. Pull it through the // service worker (which caches thumbnail responses) so a video saved for // offline still has its art when the network is gone. +// Pull a result set's artwork into the service worker's thumbnail cache, a few at +// a time, so a saved search still has pictures offline. +function warmSearchThumbs(results) { + const urls = (results || []).map((r) => r && r.thumbnail).filter(Boolean).slice(0, 50); + let i = 0; + const next = () => { if (i >= urls.length) return; warmThumb(urls[i++]); setTimeout(next, 40); }; + next(); next(); next(); +} function warmThumb(url) { if (!WEB || !url || !/^https?:/i.test(url)) return; fetch(url, { mode: 'cors', credentials: 'omit' }).catch(() => {}); @@ -4482,7 +4548,7 @@ const Remote = (() => { } catch { found = []; } results.innerHTML = found.length ? found.slice(0, 20).map((v, i) => ` `).join('') : '
No results.
'; }); @@ -6905,7 +6971,7 @@ function renderUpNext() { const item = document.createElement('div'); item.className = 'upnext-item'; item.innerHTML = ` - +
@@ -7333,7 +7399,7 @@ function renderHomePlaylists() { card.type = 'button'; card.innerHTML = ` - ${thumb ? `` + ${thumb ? `` : '🎵'} ${offlineHtml} ▶ @@ -7414,6 +7480,7 @@ function renderList() { if (view.type === 'settings') { renderSettings(); return; } if (view.type === 'saved') { renderSaved(); return; } if (view.type === 'p2p') { renderP2pList(); return; } + if (view.type === 'savedSearches') { renderSavedSearches(); return; } if (view.type === 'downloads') { renderDownloads(); return; } if (view.type === 'stats') { renderStats(); return; } if (view.type === 'notes') { renderNotesView(); return; } @@ -7776,6 +7843,47 @@ async function renderP2pStrip() { box.classList.remove('hidden'); } + +// ============================================================================ +// Saved searches — browse (read-only here; deleting happens in Settings) +// ============================================================================ +function fmtAgo(ts) { + const s = Math.max(0, (Date.now() - ts) / 1000); + if (s < 90) return 'just now'; + if (s < 3600) return Math.round(s / 60) + ' min ago'; + if (s < 86400) return Math.round(s / 3600) + ' h ago'; + return Math.round(s / 86400) + ' d ago'; +} +function renderSavedSearches() { + els.listTitle.textContent = '📚 Saved searches'; + els.listActions.innerHTML = ''; + els.status.classList.add('hidden'); + const c = els.cards; + c.innerHTML = ''; + const items = SearchLibrary.list(); + if (!items.length) { + c.innerHTML = '
📚

No saved searches yet

Every search you run is kept here with its thumbnails, even after you clear the search box. You can remove them in Settings.

'; + return; + } + const sum = document.createElement('div'); + sum.className = 'saved-summary'; + sum.innerHTML = `${items.length}saved search${items.length === 1 ? '' : 'es'} · ${fmtBytes(SearchLibrary.bytes())} · manage in Settings`; + c.appendChild(sum); + items.forEach((it) => { + const row = document.createElement('div'); + row.className = 'card saved-card ss-card'; + row.innerHTML = ` +
+
${it.count} result${it.count === 1 ? '' : 's'} · ${fmtAgo(it.ts)}
`; + row.querySelector('.card-title').textContent = it.query; + row.addEventListener('click', () => { + els.searchInput.value = it.query; + if (els.runSearch) els.runSearch(it.query, { instant: SearchLibrary.get(it.query) }); + }); + c.appendChild(row); + }); +} + async function renderP2pList() { els.listTitle.textContent = '📡 On other devices'; els.listActions.innerHTML = ''; @@ -7822,7 +7930,7 @@ async function renderP2pList() { row.dataset.id = it.id; const state = it.online ? `● ${it.online} online` : '○ none online'; row.innerHTML = ` -
+
${state} · ${it.holders} device${it.holders === 1 ? '' : 's'} · ${fmtBytes(it.size)}${it.height ? ' · ' + it.height + 'p' : ''}${it.stale ? ' · not checked lately' : ''}
@@ -7923,7 +8031,7 @@ async function renderSaved() { row.className = 'card saved-card' + (isCustom ? ' custom' : ''); row.dataset.id = it.id; row.innerHTML = ` -
${isCustom ? '✂' : ''}${isUploadId(v.id) ? `${v.kind === 'audio' ? '♪' : '📁'}` : ''}
@@ -7990,7 +8098,7 @@ function renderDownloads() { row.dataset.id = v.id; row.innerHTML = `
- +
@@ -8179,6 +8287,23 @@ async function renderSettings() {
+
+
Saved searches
+
+ + Kept on this device + Search results and their thumbnails stay saved after you clear the search box. Only you can remove them, here. + + … +
+
+ + + +
+
+
+
Playlists
@@ -8288,6 +8413,40 @@ async function renderSettings() { $('setRepeat').addEventListener('change', (e) => { data.settings.repeatMode = e.target.checked ? 'all' : 'off'; updateLoopRepeatButtons(); persist(); }); $('setLoopOne').addEventListener('change', (e) => { data.settings.loopOne = e.target.checked; updateLoopRepeatButtons(); persist(); }); + + // ---- Saved searches manager ---- + { + const listEl = $('ssList'); + const sel = new Set(); + const refresh = () => { + const items = SearchLibrary.list(); + for (const k of [...sel]) if (!items.some((i) => i.key === k)) sel.delete(k); + $('ssTotal').textContent = items.length ? `${items.length} · ${fmtBytes(SearchLibrary.bytes())}` : 'None'; + listEl.innerHTML = items.length ? items.map((i) => ` + `).join('') : '
No saved searches.
'; + $('ssDeleteSel').disabled = !sel.size; + $('ssDeleteSel').textContent = sel.size ? `Delete selected (${sel.size})` : 'Delete selected'; + $('ssSelectAll').textContent = items.length && sel.size === items.length ? 'Select none' : 'Select all'; + $('ssClearAll').disabled = !items.length; + }; + listEl.addEventListener('change', (e) => { const k = e.target.dataset && e.target.dataset.k; if (!k) return; e.target.checked ? sel.add(k) : sel.delete(k); refresh(); }); + $('ssSelectAll').addEventListener('click', () => { + const items = SearchLibrary.list(); + if (sel.size === items.length) sel.clear(); else items.forEach((i) => sel.add(i.key)); + refresh(); + }); + $('ssDeleteSel').addEventListener('click', () => { + if (!sel.size || !confirm(`Delete ${sel.size} saved search${sel.size === 1 ? '' : 'es'}? Their results and thumbnails are removed from this device.`)) return; + SearchLibrary.remove([...sel]); sel.clear(); refresh(); toast('Saved searches deleted'); + }); + $('ssClearAll').addEventListener('click', () => { + if (!confirm('Delete ALL saved searches and their thumbnails from this device?')) return; + SearchLibrary.clear(); sel.clear(); refresh(); toast('All saved searches deleted'); + }); + refresh(); + } + // ---- Wire playback controls ---- $('setQuality').addEventListener('change', (e) => { data.settings.quality = e.target.value; @@ -8520,7 +8679,7 @@ function renderCard(v, index, list) { card.innerHTML = `
- + ${v.duration ? `${fmtTime(v.duration)}` : ''} ${isDownloading ? '' : '⬇'} ${isDownloading ? '
' : ''} @@ -9404,6 +9563,7 @@ function wireUI() { searchAttempted = true; renderList(); RecentSearches.cacheResults(q, results); + warmSearchThumbs(results); warmStreams(results.slice(0, 3).map((r) => r.id)); perfMeasure('ytp:search', 'ytp:search-start'); } catch (err) { @@ -9415,6 +9575,7 @@ function wireUI() { } } + els.runSearch = runSearchQuery; els.searchForm.addEventListener('submit', (e) => { e.preventDefault(); runSearchQuery(els.searchInput.value); @@ -9423,7 +9584,7 @@ function wireUI() { // ---- Recent searches dropdown (device-local; see RecentSearches above) ---- function renderRecentSearches() { const list = RecentSearches.list(); - if (!list.length) { els.searchRecent.innerHTML = ''; return; } + if (!list.length && !SearchLibrary.count()) { els.searchRecent.innerHTML = ''; return; } const cachedQueries = RecentSearches.cachedQueries(); els.searchRecent.innerHTML = list.map((q) => { const isCached = cachedQueries.has(q.trim().toLowerCase()); @@ -9432,11 +9593,12 @@ function wireUI() { ${isCached ? '⚡' : '🕐'}${escapeHtml(q)}
`; - }).join('') + ''; + }).join('') + `` + + (SearchLibrary.count() ? `` : ''); } function showRecentSearches() { renderRecentSearches(); - if (!RecentSearches.list().length) return; + if (!RecentSearches.list().length && !SearchLibrary.count()) return; els.searchRecent.classList.remove('hidden'); } function hideRecentSearches() { els.searchRecent.classList.add('hidden'); } @@ -9455,6 +9617,13 @@ function wireUI() { showRecentSearches(); return; } + if (e.target.closest('[data-open-saved]')) { + e.preventDefault(); + hideRecentSearches(); + view = { type: 'savedSearches' }; + render(); + return; + } if (e.target.closest('[data-clear-all]')) { e.preventDefault(); RecentSearches.clear(); @@ -10105,7 +10274,7 @@ function renderRelated() { relatedVideos.forEach((v) => { const item = document.createElement('div'); item.className = 'related-item'; - item.innerHTML = `
`; + item.innerHTML = `
`; item.querySelector('.ri-title').textContent = v.title; const riChEl = item.querySelector('.ri-channel'); riChEl.textContent = v.channel || ''; diff --git a/frontend/index.html b/frontend/index.html index 040ae6f..85facd4 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -20,6 +20,7 @@ + diff --git a/frontend/refresh.css b/frontend/refresh.css index ca2ebc0..4065d45 100644 --- a/frontend/refresh.css +++ b/frontend/refresh.css @@ -535,3 +535,13 @@ } @media (max-width: 380px) { #nowPlayingMeta .np-actions #addBtn, #nowPlayingMeta .np-actions #serviceBtn { flex-basis: calc(50% - 8px); } } .player-stage { flex-shrink: 0; } + +/* Saved searches */ +.ss-list { max-height: 280px; overflow-y: auto; margin-top: 8px; border: 1px solid var(--line); border-radius: 14px; } +.ss-row { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-bottom: 1px solid var(--line-soft, var(--line)); cursor: pointer; } +.ss-row:last-child { border-bottom: 0; } +.ss-row input { width: 18px; height: 18px; accent-color: var(--accent); flex: none; } +.ss-q { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ss-row small { color: var(--text-dim); flex: none; font-size: 11px; } +.search-recent-clear small { opacity: 0.6; } +.topbar { position: relative; z-index: 80; } /* keeps the recent-searches dropdown above the page content */ diff --git a/frontend/sw.js b/frontend/sw.js index e2c5820..9ef0e38 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -46,7 +46,7 @@ const THUMB_HOSTS = new Set([ ]); // Thumbnails accumulate with every card ever scrolled past — keep it bounded. -const THUMB_CACHE_MAX = 800; +const THUMB_CACHE_MAX = 2500; function isVersionedShellCache(key) { return key.startsWith(SHELL_CACHE_PREFIX) && !UTILITY_CACHES.has(key); } diff --git a/server/server.js b/server/server.js index d5caef9..0d07ffe 100644 --- a/server/server.js +++ b/server/server.js @@ -396,46 +396,69 @@ app.get('/api/version', (c) => // resolveStreams()' streamCache below, just keyed by normalized query text // instead of videoId. Only a full success is cached, so a transient yt-dlp // failure still gets retried on the next request. -const SEARCH_CACHE_MAX = 100; -const SEARCH_CACHE_TTL_MS = 3 * 60_000; +const SEARCH_CACHE_MAX = 200; +const SEARCH_CACHE_TTL_MS = 10 * 60_000; // fresh: answered from memory +const SEARCH_CACHE_STALE_MS = 6 * 3600_000; // stale-while-revalidate window const searchCache = new Map(); // lowercased q -> { results, expiresAt } +const inflightSearches = new Map(); // lowercased q -> Promise<{ results, youtubeError? }> + +// One search, shared by every caller asking the same thing at the same time. +function fetchSearch(q) { + const key = q.toLowerCase(); + if (inflightSearches.has(key)) return inflightSearches.get(key); + const p = (async () => { + // This server's own library first — and it still answers when YouTube + // (yt-dlp) is unreachable or rate-limited. + let mine = []; + try { mine = (await notesDb.listUploads({ q, limit: 20 })).map(uploads.card); } catch { /* library optional */ } + try { + let yt = []; + if (process.env.SEARCH_INNERTUBE !== '0') { + try { + yt = await innertube.search(q); + } catch (e) { + console.warn(`[search] innertube failed, using yt-dlp: ${e.message}`); + } + } + if (!yt.length) { + const out = await runYtdlpResilient([ + `ytsearch${SEARCH_LIMIT}:${q}`, + '--dump-json', '--flat-playlist', + '--no-warnings', '--ignore-errors', + ], { pooled: true }); + yt = parseCards(out); + } + const results = [...mine, ...yt]; + if (searchCache.size >= SEARCH_CACHE_MAX) searchCache.delete(searchCache.keys().next().value); + searchCache.set(key, { results, expiresAt: Date.now() + SEARCH_CACHE_TTL_MS }); + return { results }; + } catch (err) { + if (mine.length) return { results: mine, youtubeError: err.message }; + throw err; + } + })().finally(() => inflightSearches.delete(key)); + inflightSearches.set(key, p); + return p; +} // GET /api/search?q= +// Fresh cache → instant. Stale (up to 6 h) → answered instantly from the old +// copy while a refresh runs in the background, so a repeat search never waits +// on yt-dlp/InnerTube. Only a full success is cached, so a transient failure +// still gets retried on the next request. app.get('/api/search', async (c) => { const q = (c.req.query('q') || '').trim(); if (!q) return c.json({ ok: false, error: 'empty query' }, 400); - - const cacheKey = q.toLowerCase(); - const cached = searchCache.get(cacheKey); + const cached = searchCache.get(q.toLowerCase()); + const age = cached ? Date.now() - (cached.expiresAt - SEARCH_CACHE_TTL_MS) : Infinity; if (cached && Date.now() < cached.expiresAt) return c.json({ ok: true, results: cached.results }); - - // This server's own library first — and it still answers when YouTube - // (yt-dlp) is unreachable or rate-limited. - let mine = []; - try { mine = (await notesDb.listUploads({ q, limit: 20 })).map(uploads.card); } catch { /* library optional */ } + if (cached && age < SEARCH_CACHE_STALE_MS) { + fetchSearch(q).catch(() => { /* keep serving the stale copy */ }); + return c.json({ ok: true, results: cached.results, stale: true }); + } try { - let yt = []; - if (process.env.SEARCH_INNERTUBE !== '0') { - try { - yt = await innertube.search(q); - } catch (e) { - console.warn(`[search] innertube failed, using yt-dlp: ${e.message}`); - } - } - if (!yt.length) { - const out = await runYtdlpResilient([ - `ytsearch${SEARCH_LIMIT}:${q}`, - '--dump-json', '--flat-playlist', - '--no-warnings', '--ignore-errors', - ], { pooled: true }); - yt = parseCards(out); - } - const results = [...mine, ...yt]; - if (searchCache.size >= SEARCH_CACHE_MAX) searchCache.delete(searchCache.keys().next().value); - searchCache.set(cacheKey, { results, expiresAt: Date.now() + SEARCH_CACHE_TTL_MS }); - return c.json({ ok: true, results }); + return c.json({ ok: true, ...(await fetchSearch(q)) }); } catch (err) { - if (mine.length) return c.json({ ok: true, results: mine, youtubeError: err.message }); return c.json({ ok: false, error: err.message }, 500); } }); @@ -2172,7 +2195,11 @@ app.get('/*', async (c, next) => { const ext = p.slice(p.lastIndexOf('.') + 1).toLowerCase(); return sendCompressed(c, compressedEntry(p, raw, MIME[ext] || 'application/octet-stream'), 'no-cache'); }); -app.use('/*', serveStatic({ root: './public', onFound: (_path, c) => { c.header('Cache-Control', 'no-cache'); } })); +// Self-hosted fonts and icons rarely change: let the browser keep them for 30 days +// (the service worker precaches the shell anyway); everything else revalidates. +app.use('/*', serveStatic({ root: './public', onFound: (path, c) => { + c.header('Cache-Control', /(^|\/)(fonts|icons)\//.test(path) ? 'public, max-age=2592000' : 'no-cache'); +} })); // SPA fallback — return index.html for any unmatched path app.get('/*', indexHtml);