Search: server-known videos first with YouTube results appended as they arrive; On server badge on cards the server holds a copy of
This commit is contained in:
@@ -8014,15 +8014,24 @@ function wireUI() {
|
|||||||
scrollListIntoViewPortrait();
|
scrollListIntoViewPortrait();
|
||||||
const metaBtn = $('searchUpdateBtn');
|
const metaBtn = $('searchUpdateBtn');
|
||||||
if (metaBtn) { metaBtn.disabled = true; metaBtn.textContent = 'Updating…'; }
|
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) {
|
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) => {
|
API.searchLocal(q).then((lr) => {
|
||||||
if (networkDone || mySeq !== searchSeq || !lr || !lr.ok || !(lr.results || []).length) return;
|
if (networkDone || mySeq !== searchSeq || !lr || !lr.ok || !(lr.results || []).length) return;
|
||||||
paintedLocal = true;
|
paintedLocal = true;
|
||||||
|
localHits = lr.results;
|
||||||
searchResults = lr.results;
|
searchResults = lr.results;
|
||||||
searchAttempted = true;
|
searchAttempted = true;
|
||||||
renderList();
|
renderList();
|
||||||
|
if (els.status) { els.status.classList.remove('hidden'); els.status.textContent = 'Searching YouTube for more…'; }
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -8030,7 +8039,8 @@ function wireUI() {
|
|||||||
networkDone = true;
|
networkDone = true;
|
||||||
if (mySeq !== searchSeq) return; // superseded by a newer search
|
if (mySeq !== searchSeq) return; // superseded by a newer search
|
||||||
if (!res || !res.ok) throw new Error(res?.error || 'Search failed');
|
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;
|
searchResults = results;
|
||||||
searchAttempted = true;
|
searchAttempted = true;
|
||||||
renderList();
|
renderList();
|
||||||
@@ -8047,17 +8057,18 @@ function wireUI() {
|
|||||||
if (mySeq !== searchSeq || view.type !== 'search') return;
|
if (mySeq !== searchSeq || view.type !== 'search') return;
|
||||||
try {
|
try {
|
||||||
const more = await API.search(q);
|
const more = await API.search(q);
|
||||||
if (mySeq !== searchSeq || !more?.ok || (more.results || []).length <= results.length) return;
|
const full = merged(more?.results || []);
|
||||||
searchResults = more.results;
|
if (mySeq !== searchSeq || !more?.ok || full.length <= results.length) return;
|
||||||
|
searchResults = full;
|
||||||
renderList();
|
renderList();
|
||||||
RecentSearches.cacheResults(q, more.results, Date.now());
|
RecentSearches.cacheResults(q, full, Date.now());
|
||||||
showSearchMeta(q, Date.now());
|
showSearchMeta(q, Date.now());
|
||||||
} catch { /* keep the first page */ }
|
} catch { /* keep the first page */ }
|
||||||
}, 10000);
|
}, 10000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
networkDone = true;
|
networkDone = true;
|
||||||
if (mySeq !== searchSeq) return;
|
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
|
if (instant) { showSearchMeta(q, instant.ts); if (refresh) toast('Could not update: ' + err.message); return; } // keep the saved copy
|
||||||
els.cards.innerHTML = '';
|
els.cards.innerHTML = '';
|
||||||
els.status.classList.remove('hidden');
|
els.status.classList.remove('hidden');
|
||||||
|
|||||||
@@ -1039,6 +1039,13 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
|
|||||||
backdrop-filter: blur(2px);
|
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-info { min-width: 0; flex: 1; display: flex; flex-direction: column; justify-content: center; }
|
||||||
.card-title {
|
.card-title {
|
||||||
font-size: 13.5px; font-weight: 600; line-height: 1.32;
|
font-size: 13.5px; font-weight: 600; line-height: 1.32;
|
||||||
|
|||||||
@@ -1564,6 +1564,8 @@ function renderCard(v, index, list) {
|
|||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
const isCached = cachedIds.has(v.id);
|
const isCached = cachedIds.has(v.id);
|
||||||
const isDownloading = downloading.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.className = 'card' + (isCached ? ' cached' : '') + (isDownloading ? ' downloading' : '') + (selectedIds.has(v.id) ? ' selected' : '');
|
||||||
card.dataset.id = v.id;
|
card.dataset.id = v.id;
|
||||||
card._video = v; // read by f7-layout.js swipe actions
|
card._video = v; // read by f7-layout.js swipe actions
|
||||||
@@ -1572,6 +1574,7 @@ function renderCard(v, index, list) {
|
|||||||
<div class="thumb">
|
<div class="thumb">
|
||||||
<img loading="lazy" decoding="async" src="${escapeHtml(v.thumbnail || '')}" alt="" />
|
<img loading="lazy" decoding="async" src="${escapeHtml(v.thumbnail || '')}" alt="" />
|
||||||
${v.duration ? `<span class="dur">${fmtTime(v.duration)}</span>` : ''}
|
${v.duration ? `<span class="dur">${fmtTime(v.duration)}</span>` : ''}
|
||||||
|
${onServer ? '<span class="server-badge" title="A copy is on the server: starts fast and plays even when YouTube is slow"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="4" width="16" height="6" rx="1.5"/><rect x="4" y="14" width="16" height="6" rx="1.5"/><path d="M8 7h.01M8 17h.01"/></svg>On server</span>' : ''}
|
||||||
<span class="saved-badge" title="${isDownloading ? 'Downloading…' : 'Saved offline'}">${MediaIcons.svg('offline', 'badge-offline')}${MediaIcons.svg('progress', 'badge-progress')}</span>
|
<span class="saved-badge" title="${isDownloading ? 'Downloading…' : 'Saved offline'}">${MediaIcons.svg('offline', 'badge-offline')}${MediaIcons.svg('progress', 'badge-progress')}</span>
|
||||||
${isDownloading ? '<div class="dl-progress"><div class="dl-bar"></div></div>' : ''}
|
${isDownloading ? '<div class="dl-progress"><div class="dl-bar"></div></div>' : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -455,8 +455,24 @@ const searchLibrary = async (q) => {
|
|||||||
// This server's own library first — it still answers when YouTube is unreachable.
|
// 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 []; }
|
try { return (await notesDb.listUploads({ q, limit: 20, listedOnly: true })).map(uploads.card); } catch { return []; }
|
||||||
};
|
};
|
||||||
const withYoutubeUrl = card => card && /^[\w-]{11}$/.test(card.id || '')
|
// Ids the server holds a ready copy of, refreshed at most once a minute, so
|
||||||
? { ...card, url: `https://www.youtube.com/watch?v=${card.id}` } : card;
|
// 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=<query> — videos this server already knows (from every
|
// GET /api/search/local?q=<query> — videos this server already knows (from every
|
||||||
// earlier search) plus its own uploads; answers in milliseconds so the app can
|
// 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) => {
|
app.get('/api/search/local', async (c) => {
|
||||||
const q = (c.req.query('q') || '').trim();
|
const q = (c.req.query('q') || '').trim();
|
||||||
if (!q) return c.json({ ok: false, error: 'empty query' }, 400);
|
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 });
|
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);
|
if (!q) return c.json({ ok: false, error: 'empty query' }, 400);
|
||||||
const key = q.toLowerCase();
|
const key = q.toLowerCase();
|
||||||
const mine = await searchLibrary(q);
|
const mine = await searchLibrary(q);
|
||||||
|
copyIds = await serverCopyIds();
|
||||||
const answer = (yt, extra = {}) => {
|
const answer = (yt, extra = {}) => {
|
||||||
const body = JSON.stringify({ ok: true, results: [...mine, ...yt].map(withYoutubeUrl), ...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' };
|
const headers = { 'Content-Type': 'application/json; charset=utf-8', Vary: 'Accept-Encoding' };
|
||||||
|
|||||||
Reference in New Issue
Block a user