Fix: the server crashed on every Search; Search now covers tasks and events and explains itself
build-container / build (push) Successful in 1m55s
build-container / build (push) Successful in 1m55s
- Restore ddgAnswer, which an earlier edit deleted: /api/ddg threw a ReferenceError and killed the whole server process whenever Search ran - One failing request can no longer take the server down (errors are logged and answered with a 500) - tools/smoke-test.js starts the real server and calls every endpoint; the Gitea workflow runs it before building the image, so a missing function or a crash stops the build - Search: also searches NE Hub tasks and calendar events; Seafile file search falls back to scanning file names when the search index returns nothing; Plex search falls back to the library's artists/albums; a status line shows what was searched, per-source counts and errors, with sign-in links Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ea882cafbf
commit
489ea0aaf0
8 files changed
+229
-40
No files matched your search
+73
-24
@@ -221,59 +221,108 @@ async function walkSearch(repos, term) {
|
||||
}
|
||||
|
||||
// ---- Search: Seafile files + libraries and Plex music, all best-effort (a missing backend never blocks the others)
|
||||
// Seafile: use the search index when the server has one; if it is missing OR comes back empty, scan file names library by library.
|
||||
async function searchFiles(term, libs) {
|
||||
const enc = encodeURIComponent, lower = term.toLowerCase();
|
||||
let results = [], via = 'index';
|
||||
try { results = (await sf.req(`/api2/search/?q=${enc(term)}&per_page=20`)).results || []; } catch {}
|
||||
if (!results.length) { results = await walkSearch(libs, lower); via = 'scan'; }
|
||||
return { results, via, scanned: libs.filter(l => !l.encrypted).length };
|
||||
}
|
||||
|
||||
// Plex: the server-wide search first; if that finds nothing, filter the artists and albums of the chosen music library directly.
|
||||
async function searchMusic(term) {
|
||||
const enc = encodeURIComponent, lower = term.toLowerCase(), items = [];
|
||||
try {
|
||||
const mc = await px.get(`/hubs/search?query=${enc(term)}&limit=10`);
|
||||
for (const hub of mc.Hub || []) for (const m of hub.Metadata || []) if (['artist', 'album', 'track'].includes(m.type)) items.push(m);
|
||||
} catch {}
|
||||
if (!items.length) {
|
||||
const sec = await px.section(), big = '&X-Plex-Container-Start=0&X-Plex-Container-Size=3000&sort=titleSort';
|
||||
const [artists, albums] = await Promise.all([px.get(`/library/sections/${sec}/all?type=8${big}`), px.get(`/library/sections/${sec}/all?type=9${big}`)]);
|
||||
for (const a of artists.Metadata || []) if ((a.title || '').toLowerCase().includes(lower)) items.push({ ...a, type: 'artist' });
|
||||
for (const a of albums.Metadata || []) if (((a.title || '') + ' ' + (a.parentTitle || '')).toLowerCase().includes(lower)) items.push({ ...a, type: 'album' });
|
||||
}
|
||||
return items.slice(0, 16);
|
||||
}
|
||||
|
||||
function viewSearch() {
|
||||
$('#view').innerHTML = `<div class="wrap"><h1>Search</h1><input type="search" id="q" placeholder="Files, libraries, artists, albums, tracks" autocomplete="off" autocapitalize="off">
|
||||
$('#view').innerHTML = `<div class="wrap"><h1>Search</h1><input type="search" id="q" placeholder="Tasks, events, files, music…" autocomplete="off" autocapitalize="off">
|
||||
<p class="sub" id="src" style="margin:8px 0 0"></p>
|
||||
<div class="bar" id="web"><a class="btn ghost" id="duckai" href="https://duck.ai/" target="_blank" rel="noopener">${icon('search')}Ask Duck.ai</a>
|
||||
<a class="btn ghost" id="ddg" href="https://duckduckgo.com/" target="_blank" rel="noopener">${icon('search')}Search the web</a></div>
|
||||
<div id="res"></div></div>`;
|
||||
const q = $('#q'); q.focus();
|
||||
let timer, seq = 0;
|
||||
let timer, seq = 0, plexSt = null;
|
||||
// One line that says what Search covers and what each source returned, with a link to sign in where a source needs it.
|
||||
const showSources = (r = {}) => {
|
||||
const el = $('#src'); if (!el) return;
|
||||
const link = (href, t) => `<a href="${href}" style="color:var(--accent)">${t}</a>`, bad = t => `<span style="color:var(--danger)">${esc(t)}</span>`;
|
||||
const part = (st, off, label) => st === undefined ? off : st.err ? bad('error: ' + st.err) : `✓${st.n !== undefined ? ' ' + st.n : ''}`;
|
||||
const filesOff = !sf.token() ? link('#/files', 'sign in to search files') : '✓';
|
||||
const musicOff = !plexSt || !plexSt.signedIn ? link('#/music', 'sign in to search music') : !plexSt.server ? link('#/music', 'choose a Plex server') : '✓';
|
||||
el.innerHTML = `Searching: Tasks ${part(r.tasks, '✓')} · Events ${part(r.events, '✓')} · Files ${part(r.files, filesOff)} · Music ${part(r.music, musicOff)}`;
|
||||
};
|
||||
jfetch('/api/plex/status').then(st => { plexSt = st; showSources(); }).catch(() => showSources());
|
||||
showSources();
|
||||
// Duck.ai and DuckDuckGo refuse to be shown inside other apps, so these hand the question over in a new tab.
|
||||
const webLinks = () => { const t = encodeURIComponent(q.value.trim()); $('#duckai').href = 'https://duck.ai/' + (t ? '?q=' + t : ''); $('#ddg').href = 'https://duckduckgo.com/' + (t ? '?q=' + t : ''); };
|
||||
q.oninput = () => { webLinks(); clearTimeout(timer); timer = setTimeout(() => runSearch(q.value.trim(), ++seq), 350); };
|
||||
async function runSearch(term, mySeq) {
|
||||
const res = $('#res');
|
||||
if (term.length < 2) { res.innerHTML = ''; return; }
|
||||
if (term.length < 2) { res.innerHTML = ''; showSources(); return; }
|
||||
res.innerHTML = '<p class="empty">Searching…</p>';
|
||||
const enc = encodeURIComponent;
|
||||
const [files, libs, music, ddg] = await Promise.allSettled([
|
||||
sf.token() ? sf.req(`/api2/search/?q=${enc(term)}&per_page=20`) : Promise.reject(new Error('signed out')),
|
||||
sf.token() ? sf.req('/api2/repos/') : Promise.reject(new Error('signed out')),
|
||||
jfetch('/api/plex/status').then(st => st.server ? px.get(`/hubs/search?query=${enc(term)}&limit=8`) : Promise.reject(new Error('no server'))),
|
||||
const enc = encodeURIComponent, lower = term.toLowerCase();
|
||||
const hasSf = !!sf.token(), plexReady = !!(plexSt && plexSt.signedIn && plexSt.server);
|
||||
const libsP = hasSf ? sf.req('/api2/repos/') : Promise.reject(new Error('signed out'));
|
||||
const [libs, files, music, ddg, hub] = await Promise.allSettled([
|
||||
libsP,
|
||||
libsP.then(l => searchFiles(term, l)),
|
||||
plexReady ? searchMusic(term) : Promise.reject(new Error('not connected')),
|
||||
jfetch('/api/ddg?q=' + enc(term)),
|
||||
jfetch('/api/search?q=' + enc(term)),
|
||||
]);
|
||||
if (mySeq !== seq || !location.hash.startsWith('#/search')) return;
|
||||
const sec = (title, html) => html ? `<h2>${title}</h2><div class="list">${html}</div>` : '';
|
||||
const lower = term.toLowerCase();
|
||||
const libHtml = libs.status === 'fulfilled' ? libs.value.filter(r => r.name.toLowerCase().includes(lower)).map(r =>
|
||||
`<a class="row" href="#/files/${enc(r.id)}"><span class="ic" style="background:#2f9e6e">${icon('files')}</span><span class="grow"><b>${esc(r.name)}</b><small>Library</small></span></a>`).join('') : '';
|
||||
let found = files.status === 'fulfilled' ? files.value.results || [] : null;
|
||||
if (!found && libs.status === 'fulfilled') found = await walkSearch(libs.value, lower); // free Seafile has no search index; scan names instead
|
||||
if (mySeq !== seq || !location.hash.startsWith('#/search')) return;
|
||||
const fileHtml = found ? found.map(f => {
|
||||
const found = files.status === 'fulfilled' ? files.value.results : [];
|
||||
const fileHtml = found.map(f => {
|
||||
const parent = f.fullpath.slice(0, f.fullpath.lastIndexOf('/')) || '/';
|
||||
return f.is_dir ? `<a class="row" href="#/files/${enc(f.repo_id)}${f.fullpath.split('/').filter(Boolean).map(x => '/' + enc(x)).join('')}"><span class="ic" style="background:#e0a82b">${icon('files')}</span><span class="grow"><b>${esc(f.name)}</b><small>${esc(f.repo_name || '')} ${esc(f.fullpath)}</small></span></a>`
|
||||
: `<button class="row" data-f="${esc(JSON.stringify([f.repo_id, f.fullpath, f.name, parent]))}"><span class="ic" style="background:#6a7280">${icon('file')}</span><span class="grow"><b>${esc(f.name)}</b><small>${esc(f.repo_name || '')} ${esc(parent)}</small></span></button>`;
|
||||
}).join('') : '';
|
||||
}).join('');
|
||||
let musicHtml = '';
|
||||
if (music.status === 'fulfilled') {
|
||||
for (const hub of music.value.Hub || []) for (const m of hub.Metadata || []) {
|
||||
if (m.type === 'artist') musicHtml += `<a class="row" href="#/music/artist/${m.ratingKey}"><img class="art" style="width:40px;height:40px;border-radius:50%" alt="" src="${px.art(m.thumb, 80)}"><span class="grow"><b>${esc(m.title)}</b><small>Artist</small></span></a>`;
|
||||
else if (m.type === 'album') musicHtml += `<a class="row" href="#/music/album/${m.ratingKey}"><img class="art" style="width:40px;height:40px;border-radius:8px" alt="" src="${px.art(m.thumb, 80)}"><span class="grow"><b>${esc(m.title)}</b><small>Album · ${esc(m.parentTitle || '')}</small></span></a>`;
|
||||
else if (m.type === 'track') { const tr = mapTrack(m, {}); if (tr) musicHtml += `<button class="row" data-t="${esc(JSON.stringify(tr))}"><img class="art" style="width:40px;height:40px;border-radius:8px" alt="" src="${px.art(m.thumb || m.parentThumb, 80)}"><span class="grow"><b>${esc(m.title)}</b><small>Track · ${esc(m.grandparentTitle || '')}</small></span></button>`; }
|
||||
}
|
||||
for (const m of music.status === 'fulfilled' ? music.value : []) {
|
||||
if (m.type === 'artist') musicHtml += `<a class="row" href="#/music/artist/${m.ratingKey}"><img class="art" style="width:40px;height:40px;border-radius:50%" alt="" src="${px.art(m.thumb, 80)}"><span class="grow"><b>${esc(m.title)}</b><small>Artist</small></span></a>`;
|
||||
else if (m.type === 'album') musicHtml += `<a class="row" href="#/music/album/${m.ratingKey}"><img class="art" style="width:40px;height:40px;border-radius:8px" alt="" src="${px.art(m.thumb, 80)}"><span class="grow"><b>${esc(m.title)}</b><small>Album · ${esc(m.parentTitle || '')}</small></span></a>`;
|
||||
else if (m.type === 'track') { const tr = mapTrack(m, {}); if (tr) musicHtml += `<button class="row" data-t="${esc(JSON.stringify(tr))}"><img class="art" style="width:40px;height:40px;border-radius:8px" alt="" src="${px.art(m.thumb || m.parentThumb, 80)}"><span class="grow"><b>${esc(m.title)}</b><small>Track · ${esc(m.grandparentTitle || '')}</small></span></button>`; }
|
||||
}
|
||||
const note = !found && sf.token() ? '<p class="sub">Could not search Seafile files right now.</p>' : '';
|
||||
const d = ddg.status === 'fulfilled' ? ddg.value : {};
|
||||
const card = (d.text || d.answer) ? `<div class="card2" style="padding:14px;margin-top:14px;display:flex;gap:12px;align-items:flex-start">
|
||||
${d.image ? `<img alt="" src="${esc(d.image)}" referrerpolicy="no-referrer" style="width:72px;height:72px;object-fit:cover;border-radius:10px;flex:none">` : ''}
|
||||
<div style="min-width:0"><small style="color:var(--muted)">From DuckDuckGo${d.source ? ' · ' + esc(d.source) : ''}</small>
|
||||
${d.heading ? `<div style="font-weight:700;margin-top:2px">${esc(d.heading)}</div>` : ''}${d.answer ? `<div style="font-size:18px;margin-top:2px">${esc(d.answer)}</div>` : ''}
|
||||
${d.text ? `<p style="margin:4px 0 0;font-size:14px">${esc(d.text)}</p>` : ''}${d.url ? `<a href="${esc(d.url)}" target="_blank" rel="noopener" style="color:var(--accent);font-size:14px">Read more</a>` : ''}</div></div>` : '';
|
||||
res.innerHTML = card + sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + note;
|
||||
if (!libHtml && !fileHtml && !musicHtml && !card) res.innerHTML = '<p class="empty">No results</p>' + note;
|
||||
res.querySelectorAll('button[data-f]').forEach(b => b.onclick = () => { const [r, p, n, d] = JSON.parse(b.dataset.f); openFile(r, p, n, d); });
|
||||
const h = hub.status === 'fulfilled' ? hub.value : { tasks: [], events: [], errors: [] };
|
||||
evStore.length = 0;
|
||||
const taskHtml = (h.tasks || []).map(t => { const du = dueInfo(t.due);
|
||||
return `<button class="row" data-tl="${esc(t.list)}"><span class="ic" style="background:#4f7cff">${icon('check')}</span><span class="grow"><b${t.done ? ' style="text-decoration:line-through;color:var(--muted)"' : ''}>${esc(t.title)}</b><small>${esc(t.listName)}${du ? ' · ' + esc(du.text) : ''}${t.done ? ' · done' : ''}</small></span></button>`; }).join('');
|
||||
const eventHtml = (h.events || []).map(e => { const i = evStore.push(e) - 1, dt = e.allDay ? new Date(e.start + 'T00:00:00') : new Date(e.start);
|
||||
return `<button class="row" data-ei="${i}" style="text-align:left"><span class="dot" style="background:${esc(e.color)}"></span><span class="grow"><b>${esc(e.title)}</b><small>${esc(dt.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }))}${e.allDay ? ' · all day' : ' · ' + esc(fmtTime(e.start))} · ${esc(e.cal)}</small></span>${e.editable ? icon('chev', 'chev') : ''}</button>`; }).join('');
|
||||
// what each source did, so an empty screen always has an explanation
|
||||
showSources({
|
||||
tasks: hub.status === 'fulfilled' ? { n: (h.tasks || []).length } : { err: hub.reason && hub.reason.message },
|
||||
events: hub.status === 'fulfilled' ? { n: (h.events || []).length } : { err: hub.reason && hub.reason.message },
|
||||
files: !hasSf ? undefined : files.status === 'fulfilled' ? { n: found.length + libHtml.split('class="row"').length - 1 } : { err: (files.reason && files.reason.message) || (libs.reason && libs.reason.message) || 'failed' },
|
||||
music: !plexReady ? undefined : music.status === 'fulfilled' ? { n: music.value.length } : { err: music.reason && music.reason.message },
|
||||
});
|
||||
const notes = [...(h.errors || [])].map(e => `<p class="sub">${esc(e)}</p>`).join('');
|
||||
res.innerHTML = card + sec('Tasks', taskHtml) + sec('Events', eventHtml) + sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + notes;
|
||||
if (!taskHtml && !eventHtml && !libHtml && !fileHtml && !musicHtml && !card) res.innerHTML = '<p class="empty">No results. The line above shows what was searched.</p>' + notes;
|
||||
res.querySelectorAll('button[data-f]').forEach(b => b.onclick = () => { const [r, p, n, dd] = JSON.parse(b.dataset.f); openFile(r, p, n, dd); });
|
||||
res.querySelectorAll('button[data-t]').forEach(b => b.onclick = () => playQueue([JSON.parse(b.dataset.t)], 0));
|
||||
res.querySelectorAll('button[data-tl]').forEach(b => b.onclick = () => { store.set('task_list', b.dataset.tl); location.hash = '#/tasks'; });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user