diff --git a/frontend/app.js b/frontend/app.js index c47775a..8e99ed7 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -8014,15 +8014,24 @@ function wireUI() { scrollListIntoViewPortrait(); const metaBtn = $('searchUpdateBtn'); if (metaBtn) { metaBtn.disabled = true; metaBtn.textContent = 'Updating…'; } - let networkDone = false, paintedLocal = false; + let networkDone = false, paintedLocal = false, localHits = []; + // The server's own matches stay at the top; YouTube's answer is appended + // below them (no duplicates), so the list grows instead of being swapped. + const merged = (yt) => { + if (!localHits.length) return yt; + const seen = new Set(localHits.map((v) => v.id)); + return [...localHits, ...yt.filter((v) => !seen.has(v.id))]; + }; if (!instant && !refresh) { - // Known videos from the server's metadata store, painted until YouTube answers. + // Known videos from the server's metadata store, shown while YouTube searches. API.searchLocal(q).then((lr) => { if (networkDone || mySeq !== searchSeq || !lr || !lr.ok || !(lr.results || []).length) return; paintedLocal = true; + localHits = lr.results; searchResults = lr.results; searchAttempted = true; renderList(); + if (els.status) { els.status.classList.remove('hidden'); els.status.textContent = 'Searching YouTube for more…'; } }).catch(() => {}); } try { @@ -8030,7 +8039,8 @@ function wireUI() { networkDone = true; if (mySeq !== searchSeq) return; // superseded by a newer search if (!res || !res.ok) throw new Error(res?.error || 'Search failed'); - const results = res.results || []; + const results = merged(res.results || []); + if (els.status && paintedLocal) els.status.classList.add('hidden'); searchResults = results; searchAttempted = true; renderList(); @@ -8047,17 +8057,18 @@ function wireUI() { if (mySeq !== searchSeq || view.type !== 'search') return; try { const more = await API.search(q); - if (mySeq !== searchSeq || !more?.ok || (more.results || []).length <= results.length) return; - searchResults = more.results; + const full = merged(more?.results || []); + if (mySeq !== searchSeq || !more?.ok || full.length <= results.length) return; + searchResults = full; renderList(); - RecentSearches.cacheResults(q, more.results, Date.now()); + RecentSearches.cacheResults(q, full, Date.now()); showSearchMeta(q, Date.now()); } catch { /* keep the first page */ } }, 10000); } catch (err) { networkDone = true; if (mySeq !== searchSeq) return; - if (paintedLocal) return; // keep the server's known videos + if (paintedLocal) { if (els.status) els.status.classList.add('hidden'); toast('YouTube search failed — showing videos this server knows'); return; } // keep the server's known videos if (instant) { showSearchMeta(q, instant.ts); if (refresh) toast('Could not update: ' + err.message); return; } // keep the saved copy els.cards.innerHTML = ''; els.status.classList.remove('hidden'); diff --git a/frontend/styles.css b/frontend/styles.css index 3f864a0..f772af1 100755 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -1039,6 +1039,13 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); } backdrop-filter: blur(2px); } +/* Search/list cards: the server already holds a copy (fast start, no YouTube). */ +.thumb .server-badge { + position: absolute; left: 5px; top: 5px; display: inline-flex; align-items: center; gap: 3px; + padding: 2px 6px 2px 4px; border-radius: 5px; background: rgba(0,0,0,0.82); color: #fff; + font: 600 10px/1.2 var(--ui, system-ui), sans-serif; letter-spacing: 0; pointer-events: none; +} +.thumb .server-badge svg { width: 11px; height: 11px; fill: none; stroke: var(--ok, #4ade80); stroke-width: 2.2; stroke-linecap: round; } .card-info { min-width: 0; flex: 1; display: flex; flex-direction: column; justify-content: center; } .card-title { font-size: 13.5px; font-weight: 600; line-height: 1.32; diff --git a/frontend/views-core.js b/frontend/views-core.js index 56b705a..6051d94 100644 --- a/frontend/views-core.js +++ b/frontend/views-core.js @@ -1564,6 +1564,8 @@ function renderCard(v, index, list) { const card = document.createElement('div'); const isCached = cachedIds.has(v.id); const isDownloading = downloading.has(v.id); + if (v.onServer) knownServerCachedIds.add(v.id); + const onServer = !isCached && (v.onServer || knownServerCachedIds.has(v.id)); card.className = 'card' + (isCached ? ' cached' : '') + (isDownloading ? ' downloading' : '') + (selectedIds.has(v.id) ? ' selected' : ''); card.dataset.id = v.id; card._video = v; // read by f7-layout.js swipe actions @@ -1572,6 +1574,7 @@ function renderCard(v, index, list) {
${v.duration ? `${fmtTime(v.duration)}` : ''} + ${onServer ? 'On server' : ''} ${MediaIcons.svg('offline', 'badge-offline')}${MediaIcons.svg('progress', 'badge-progress')} ${isDownloading ? '
' : ''}
diff --git a/server/server.js b/server/server.js index cf91b4f..7b56eef 100644 --- a/server/server.js +++ b/server/server.js @@ -455,8 +455,24 @@ const searchLibrary = async (q) => { // This server's own library first — it still answers when YouTube is unreachable. try { return (await notesDb.listUploads({ q, limit: 20, listedOnly: true })).map(uploads.card); } catch { return []; } }; -const withYoutubeUrl = card => card && /^[\w-]{11}$/.test(card.id || '') - ? { ...card, url: `https://www.youtube.com/watch?v=${card.id}` } : card; +// Ids the server holds a ready copy of, refreshed at most once a minute, so +// search results can say "On server" without a query per card. +let serverCopies = { at: 0, ids: new Set(), loading: null }; +async function serverCopyIds() { + if (Date.now() - serverCopies.at < 60_000) return serverCopies.ids; + serverCopies.loading ||= notesDb.db.execute("SELECT video_id FROM media_cache WHERE status = 'ready'") + .then((r) => { serverCopies = { at: Date.now(), ids: new Set(r.rows.map((x) => x.video_id)), loading: null }; }) + .catch(() => { serverCopies.loading = null; serverCopies.at = Date.now(); }); + await serverCopies.loading; + return serverCopies.ids; +} +let copyIds = new Set(); +const withYoutubeUrl = card => { + if (!card) return card; + const out = /^[\w-]{11}$/.test(card.id || '') ? { ...card, url: `https://www.youtube.com/watch?v=${card.id}` } : { ...card }; + if (copyIds.has(card.id) || String(card.id || '').startsWith('upl_')) out.onServer = true; + return out; +}; // GET /api/search/local?q= — videos this server already knows (from every // earlier search) plus its own uploads; answers in milliseconds so the app can @@ -464,7 +480,8 @@ const withYoutubeUrl = card => card && /^[\w-]{11}$/.test(card.id || '') app.get('/api/search/local', async (c) => { const q = (c.req.query('q') || '').trim(); if (!q) return c.json({ ok: false, error: 'empty query' }, 400); - const [mine, known] = await Promise.all([searchLibrary(q), searchCacheDb.searchVideos(q).catch(() => [])]); + const [mine, known, ids] = await Promise.all([searchLibrary(q), searchCacheDb.searchVideos(q).catch(() => []), serverCopyIds()]); + copyIds = ids; return c.json({ ok: true, results: [...mine, ...known].map(withYoutubeUrl), local: true }); }); @@ -478,6 +495,7 @@ app.get('/api/search', async (c) => { if (!q) return c.json({ ok: false, error: 'empty query' }, 400); const key = q.toLowerCase(); const mine = await searchLibrary(q); + copyIds = await serverCopyIds(); const answer = (yt, extra = {}) => { const body = JSON.stringify({ ok: true, results: [...mine, ...yt].map(withYoutubeUrl), ...extra }); const headers = { 'Content-Type': 'application/json; charset=utf-8', Vary: 'Accept-Encoding' };