Add standalone Scene Search web dashboard

This commit is contained in:
Sleeps
2026-10-06 18:29:01 +01:00
parent ff721e8055
commit a87d480092
+313
View File
@@ -0,0 +1,313 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Scene Search</title>
<style>
:root { color-scheme: dark; font-family: Inter, system-ui, sans-serif; background:#0f1115; color:#f4f5f7; }
* { box-sizing:border-box; }
body { margin:0; background:#0f1115; }
main { max-width:1180px; margin:0 auto; padding:28px 18px 60px; }
h1,h2 { margin-top:0; }
.muted { color:#9ca3af; }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(300px,1fr)); gap:16px; }
.card { background:#181b21; border:1px solid #2a2f39; border-radius:14px; padding:18px; margin-bottom:16px; }
input,button { font:inherit; }
input[type=text],input[type=password] { width:100%; padding:11px 12px; border-radius:9px; border:1px solid #3a414d; background:#101318; color:#fff; }
button { padding:10px 14px; border:0; border-radius:9px; cursor:pointer; background:#3b82f6; color:white; font-weight:650; }
button.secondary { background:#303642; }
button:disabled { opacity:.5; cursor:not-allowed; }
.row { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
.between { justify-content:space-between; }
.library { display:flex; gap:10px; align-items:flex-start; padding:9px 0; border-bottom:1px solid #282d36; }
.library:last-child { border-bottom:0; }
.library small { display:block; color:#9ca3af; margin-top:3px; }
progress { width:100%; height:18px; accent-color:#3b82f6; }
.big { font-size:1.7rem; font-weight:750; }
.status { margin-top:9px; min-height:1.4em; }
.error { color:#fca5a5; }
.ok { color:#86efac; }
#results { display:grid; gap:12px; }
.result { display:grid; grid-template-columns:180px 1fr; gap:14px; background:#12151a; border:1px solid #282d36; border-radius:11px; overflow:hidden; }
.result img { width:180px; height:102px; object-fit:cover; background:#08090b; }
.result .body { padding:12px 12px 12px 0; }
.result h3 { margin:0 0 6px; }
code { background:#101318; padding:2px 5px; border-radius:5px; }
@media(max-width:640px){ .result{grid-template-columns:1fr}.result img{width:100%;height:auto}.result .body{padding:0 12px 12px} }
</style>
</head>
<body>
<main>
<div class="row between">
<div>
<h1>Scene Search</h1>
<div class="muted">Jellyfin semantic scene search and indexing dashboard</div>
</div>
<div id="health" class="muted">Checking service…</div>
</div>
<div class="card">
<div class="grid">
<div>
<label for="apiKey">API key</label>
<input id="apiKey" type="password" autocomplete="off" placeholder="SCENE_API_KEY (leave blank if disabled)">
</div>
<div style="align-self:end">
<div class="row">
<button id="saveKey" type="button">Save key locally</button>
<button id="clearKey" type="button" class="secondary">Clear</button>
</div>
<div class="muted" style="margin-top:6px">Stored only in this browser's local storage.</div>
</div>
</div>
</div>
<div class="grid">
<section class="card">
<div class="row between">
<h2>Libraries</h2>
<div class="row">
<button id="selectAll" type="button" class="secondary">Select all</button>
<button id="refreshLibraries" type="button" class="secondary">Refresh</button>
</div>
</div>
<div id="libraries" class="muted">Loading…</div>
<div class="row" style="margin-top:14px">
<button id="indexNow" type="button">Index selected</button>
<label><input id="forceIndex" type="checkbox"> Force rebuild</label>
</div>
</section>
<section class="card">
<h2>Index progress</h2>
<div class="row between">
<div id="overallText" class="big">0%</div>
<div id="overallItems" class="muted">0 / 0 items</div>
</div>
<progress id="overallProgress" max="100" value="0"></progress>
<div id="indexStatus" class="status muted">Idle</div>
<div id="indexDetail" class="muted"></div>
</section>
</div>
<section class="card">
<h2>Search</h2>
<div class="row">
<div style="flex:1; min-width:220px"><input id="searchQuery" type="text" placeholder="e.g. woman standing outside"></div>
<button id="searchButton" type="button">Search scenes</button>
</div>
<div id="searchStatus" class="status muted"></div>
<div id="results"></div>
</section>
</main>
<script>
(() => {
const $ = id => document.getElementById(id);
let statusTimer = null;
let thumbnailUrls = [];
$('apiKey').value = localStorage.getItem('sceneSearchApiKey') || '';
function headers(json=false) {
const h = {};
const key = $('apiKey').value.trim();
if (key) h.Authorization = 'Bearer ' + key;
if (json) h['Content-Type'] = 'application/json';
return h;
}
async function api(path, options={}) {
const response = await fetch(path, {...options, headers:{...headers(!!options.body), ...(options.headers||{})}});
if (!response.ok) {
let detail = '';
try { detail = (await response.json()).detail || ''; } catch (_) {}
throw new Error(detail || ('HTTP ' + response.status));
}
return response.status === 204 ? null : response.json();
}
async function refreshHealth() {
try {
const data = await fetch('/api/health').then(r => r.json());
$('health').textContent = 'Service ' + data.status + ' · ' + (data.frames || 0) + ' frames';
$('health').className = data.status === 'ok' ? 'ok' : 'muted';
} catch (e) {
$('health').textContent = 'Service unavailable';
$('health').className = 'error';
}
}
async function loadLibraries() {
$('libraries').textContent = 'Loading…';
try {
const data = await api('/api/jellyfin/libraries');
const libs = data.libraries || [];
if (!libs.length) {
$('libraries').textContent = 'No Jellyfin libraries returned.';
return;
}
$('libraries').innerHTML = libs.map(l => `
<label class="library">
<input type="checkbox" class="libraryCheck" value="${escapeHtml(l.id)}" ${l.indexed_items > 0 ? 'checked' : ''}>
<span><strong>${escapeHtml(l.name)}</strong><small>${l.indexed_items} indexed items · ${l.indexed_frames} frames</small></span>
</label>`).join('');
} catch (e) {
$('libraries').innerHTML = '<span class="error">' + escapeHtml(e.message) + '</span>';
}
}
function selectedLibraries() {
return Array.from(document.querySelectorAll('.libraryCheck:checked')).map(x => x.value);
}
function renderStatus(s) {
const percent = Number(s.overall_percent || (s.stage === 'complete' ? 100 : 0));
$('overallProgress').value = percent;
$('overallText').textContent = percent.toFixed(1).replace('.0','') + '%';
$('overallItems').textContent = (s.overall_items_completed || 0) + ' / ' + (s.overall_items_total || 0) + ' items';
if (s.running) {
$('indexStatus').className = 'status';
$('indexStatus').textContent = s.stage === 'sync' || s.stage === 'syncing'
? 'Rebuilding Meilisearch'
: 'Indexing ' + (s.library || '');
const bits = [];
if (s.library_number && s.library_total) bits.push('library ' + s.library_number + '/' + s.library_total);
if (s.item) bits.push(s.item);
if (s.item_number && s.item_total) bits.push('item ' + s.item_number + '/' + s.item_total);
if (s.frames_total) bits.push((s.frames_processed || 0) + '/' + s.frames_total + ' frames');
if (s.documents_processed && (s.stage === 'sync' || s.stage === 'syncing')) bits.push(s.documents_processed + ' documents');
$('indexDetail').textContent = bits.join(' · ');
$('indexNow').disabled = true;
} else if (s.stage === 'failed') {
$('indexStatus').className = 'status error';
$('indexStatus').textContent = 'Failed: ' + (s.error || 'unknown error');
$('indexDetail').textContent = '';
$('indexNow').disabled = false;
} else if (s.stage === 'complete') {
$('indexStatus').className = 'status ok';
$('indexStatus').textContent = 'Complete';
$('indexDetail').textContent =
(s.indexed || 0) + ' indexed · ' + (s.skipped || 0) + ' unchanged · ' +
(s.failed || 0) + ' failed · ' + (s.pruned || 0) + ' removed';
$('indexNow').disabled = false;
} else {
$('indexStatus').className = 'status muted';
$('indexStatus').textContent = 'Idle';
$('indexDetail').textContent = '';
$('indexNow').disabled = false;
}
}
async function refreshStatus() {
try {
const s = await api('/api/jellyfin/index/status');
renderStatus(s);
if (!s.running && statusTimer) {
clearInterval(statusTimer);
statusTimer = null;
loadLibraries();
refreshHealth();
}
return s;
} catch (e) {
$('indexStatus').className = 'status error';
$('indexStatus').textContent = e.message;
return null;
}
}
async function startIndex() {
const ids = selectedLibraries();
if (!ids.length) {
$('indexStatus').className = 'status error';
$('indexStatus').textContent = 'Select at least one library.';
return;
}
try {
const s = await api('/api/jellyfin/index', {
method:'POST',
body:JSON.stringify({library_ids:ids, force:$('forceIndex').checked})
});
renderStatus(s);
if (!statusTimer) statusTimer = setInterval(refreshStatus, 2000);
} catch (e) {
$('indexStatus').className = 'status error';
$('indexStatus').textContent = e.message;
}
}
function formatTs(ms) {
const seconds = Math.floor((ms || 0) / 1000);
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
return [h,m,s].map(x => String(x).padStart(2,'0')).join(':');
}
async function loadThumbnail(frameId, img) {
try {
const response = await fetch('/api/frames/' + encodeURIComponent(frameId) + '/thumbnail', {headers:headers()});
if (!response.ok) return;
const blob = await response.blob();
const url = URL.createObjectURL(blob);
thumbnailUrls.push(url);
img.src = url;
} catch (_) {}
}
async function search() {
const q = $('searchQuery').value.trim();
if (!q) return;
thumbnailUrls.forEach(URL.revokeObjectURL);
thumbnailUrls = [];
$('results').innerHTML = '';
$('searchStatus').textContent = 'Searching…';
try {
const data = await api('/api/search?q=' + encodeURIComponent(q) + '&scene_limit=20&item_limit=10');
const scenes = data.scenes || [];
$('searchStatus').textContent = scenes.length + ' scene match' + (scenes.length === 1 ? '' : 'es');
for (const scene of scenes) {
const el = document.createElement('article');
el.className = 'result';
el.innerHTML = '<img alt=""><div class="body"><h3>' + escapeHtml(scene.title || 'Untitled') +
' <span class="muted">@ ' + formatTs(scene.timestamp_ms) + '</span></h3><div>' +
escapeHtml(scene.caption || 'No caption yet') + '</div><div class="muted" style="margin-top:6px">score ' +
(scene.ranking_score == null ? '—' : Number(scene.ranking_score).toFixed(3)) + '</div></div>';
$('results').appendChild(el);
if (scene.frame_id) loadThumbnail(scene.frame_id, el.querySelector('img'));
}
} catch (e) {
$('searchStatus').className = 'status error';
$('searchStatus').textContent = e.message;
}
}
function escapeHtml(value) {
return String(value == null ? '' : value).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
$('saveKey').onclick = () => {
localStorage.setItem('sceneSearchApiKey', $('apiKey').value.trim());
loadLibraries(); refreshStatus();
};
$('clearKey').onclick = () => {
localStorage.removeItem('sceneSearchApiKey'); $('apiKey').value = '';
loadLibraries(); refreshStatus();
};
$('refreshLibraries').onclick = loadLibraries;
$('selectAll').onclick = () => document.querySelectorAll('.libraryCheck').forEach(x => x.checked = true);
$('indexNow').onclick = startIndex;
$('searchButton').onclick = search;
$('searchQuery').addEventListener('keydown', e => { if (e.key === 'Enter') search(); });
refreshHealth();
loadLibraries();
refreshStatus().then(s => {
if (s && s.running && !statusTimer) statusTimer = setInterval(refreshStatus, 2000);
});
})();
</script>
</body>
</html>