mirror of
https://github.com/MrSleeps/jellyfin-scene-search.git
synced 2026-11-15 22:41:28 +00:00
Add standalone Scene Search web dashboard
This commit is contained in:
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|
||||
Reference in New Issue
Block a user