Add visual indicators to mark movies/series as "Downloading" when they match active SABnzbd downloads. Implement text normalization for matching download titles against media entries, with special handling for series names and episode patterns. Add purple badge/state styling for downloading status and restore original "Keine Datei/Fehler" states when downloads complete.
76 lines
15 KiB
HTML
76 lines
15 KiB
HTML
<!doctype html>
|
|
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Language Dashboard</title>
|
|
<style>
|
|
:root{color-scheme:dark;--bg:#181818;--panel:#222;--line:#3a3a3a;--text:#dedede;--muted:#929292;--yellow:#f4c430;--blue:#60a9e6}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:13px Inter,system-ui,sans-serif}header{height:58px;display:flex;align-items:center;gap:15px;padding:0 20px;background:#242424;border-bottom:1px solid #343434}.logo{color:var(--yellow);font-size:22px;font-weight:900;letter-spacing:1px}.head-title{font-weight:700}.head-sub{font-size:12px;color:var(--muted);margin-top:2px}.actions{margin-left:auto}button,input,select{font:inherit;color:var(--text);background:#292929;border:1px solid #444;border-radius:5px}button{padding:7px 10px;cursor:pointer}main{padding:14px 20px 30px}.nav{display:flex;gap:4px;margin-bottom:12px;border-bottom:1px solid var(--line)}.nav button{border:0;border-radius:5px 5px 0 0;background:transparent;color:#aaa}.nav button.active{background:#303030;color:#fff}.view{display:none}.view.active{display:block}.toolbar{display:grid;grid-template-columns:minmax(280px,1fr) 190px 190px 170px;gap:8px;margin-bottom:10px}input,select{padding:7px 10px;height:34px}.stats{display:flex;gap:8px;margin-bottom:10px;flex-wrap:wrap}.stat{background:#242424;border:1px solid #363636;padding:5px 9px;border-radius:5px;color:#bbb}.scan{color:#f4c430}.banner{padding:9px 11px;border:1px solid #693b3b;background:#372424;border-radius:5px;margin-bottom:10px;color:#ffd1d1}.tablewrap{border:1px solid #353535;border-radius:6px;overflow:hidden;background:var(--panel)}table{width:100%;border-collapse:collapse;table-layout:fixed}th{padding:8px 10px;background:#282828;border-bottom:1px solid #444;text-align:left;font-size:12px}td{padding:7px 10px;border-bottom:1px solid #343434;vertical-align:middle}.col-title{width:28%}.col-quality{width:12%}.col-state{width:10%}.col-audio{width:17%}.col-subs{width:17%}.col-video{width:8%}.col-size{width:8%}.title{color:var(--blue);font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:inline-block;max-width:90%;vertical-align:bottom}.year{color:var(--muted);font-size:11px;margin-left:5px}.badges{display:flex;gap:4px;flex-wrap:wrap}.badge{display:inline-flex;gap:4px;align-items:center;padding:2px 6px;border-radius:999px;border:1px solid #484848;background:#303030;white-space:nowrap;font-size:11px}.quality{background:#0e7138;border-color:#19894b}.state{font-size:11px;font-weight:700;padding:3px 7px;border-radius:4px;display:inline-block}.state.ok{background:#126f39;color:#d9ffe8}.state.warn{background:#77561e;color:#ffebc4}.state.bad{background:#763030;color:#ffd9d9}.state.neutral{background:#3b3b3b;color:#ccc}.muted{color:var(--muted)}.hidden{display:none}.series{border-bottom:1px solid var(--line)}.series:last-child{border:0}.series summary{padding:12px;cursor:pointer;background:#282828;font-weight:700}.series summary:hover{background:#303030}.series table{border-radius:0}.series td:first-child{padding-left:28px}@media(max-width:1100px){.toolbar{grid-template-columns:1fr 1fr}.col-video,.col-size{display:none}}
|
|
</style></head><body>
|
|
<header><div class="logo">MEDIA</div><div><div class="head-title">Language Dashboard</div><div class="head-sub">Audio- und Untertitelspuren aus den Mediendateien</div></div><div class="actions"><button id="rescan">↻ Neu scannen</button></div></header>
|
|
<main>
|
|
{% if error %}<div class="banner"><strong>Fehler:</strong> {{ error }}</div>{% endif %}
|
|
<nav class="nav"><button class="active" data-view="radarr">RADARR · Filme</button>{% if sonarr_enabled %}<button data-view="sonarr">SONARR · Serien</button>{% endif %}{% if sab_enabled %}<button data-view="downloads">SABNZBD · Downloads</button>{% endif %}</nav>
|
|
<section id="radarr" class="view active" data-source="radarr">
|
|
<div class="toolbar"><input id="search" type="search" placeholder="Film suchen …"><select id="audioFilter"><option value="">Alle Audio-Sprachen</option><option>Deutsch</option><option>Englisch</option><option>Japanisch</option><option>Koreanisch</option></select><select id="subFilter"><option value="">Alle Untertitel</option><option>Deutsch</option><option>Englisch</option><option>Japanisch</option><option>Koreanisch</option></select><select id="stateFilter"><option value="">Alle Status</option><option>DE + EN</option><option>Deutsch</option><option>English only</option></select></div>
|
|
<div class="stats"><span class="stat" id="visibleCount"></span><span class="stat scan" id="radarrStatus">Scan wird vorbereitet …</span><span class="stat">Radarr: {{ radarr_media_path }}</span></div>
|
|
<div class="tablewrap"><table id="movies"><thead><tr><th class="col-title">Film</th><th class="col-quality">Quality</th><th class="col-state">Status</th><th class="col-audio">Audio</th><th class="col-subs">Untertitel</th><th class="col-video">Video</th><th class="col-size">Größe</th></tr></thead><tbody>
|
|
{% for r in rows %}<tr data-title="{{ (r.title ~ ' ' ~ (r.year or ''))|lower }}" data-audio="{{ r.audio_languages|map(attribute='name')|join(',') }}" data-subs="{{ r.subtitle_languages|map(attribute='name')|join(',') }}" data-state="{{ r.state.label }}"><td><span class="title">{{ r.title }}</span><span class="year">{{ r.year or '' }}</span>{% if r.error %} ⚠{% endif %}</td><td><span class="badge {% if not r.missing %}quality{% endif %}">{{ r.quality }}</span></td><td><span class="state {{ r.state.class }}">{{ r.state.label }}</span></td><td><div class="badges">{% for l in r.audio_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td><div class="badges">{% for l in r.subtitle_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td>{{ r.video_codec }}</td><td>{{ r.size }}</td></tr>{% endfor %}
|
|
</tbody></table></div></section>
|
|
{% if sonarr_enabled %}<section id="sonarr" class="view" data-source="sonarr"><div class="stats"><span class="stat" id="seriesCount">{{ series|length }} Serien</span><span class="stat scan" id="sonarrStatus">Scan wird vorbereitet …</span><span class="stat">Dashboard: {{ local_series_path }}</span></div><div class="tablewrap" id="seriesList">
|
|
{% for group in series %}<details class="series" open><summary>{{ group.title }} <span class="muted">({{ group.year or '—' }}) · {{ group.episodes|length }} Episoden</span></summary><table><thead><tr><th class="col-title">Episode</th><th class="col-quality">Quality</th><th class="col-state">Status</th><th class="col-audio">Audio</th><th class="col-subs">Untertitel</th><th class="col-video">Video</th><th class="col-size">Größe</th></tr></thead><tbody>{% for r in group.episodes %}<tr><td><span class="title">{{ r.title }}</span></td><td><span class="badge {% if not r.missing %}quality{% endif %}">{{ r.quality }}</span></td><td><span class="state {{ r.state.class }}">{{ r.state.label }}</span></td><td><div class="badges">{% for l in r.audio_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td><div class="badges">{% for l in r.subtitle_languages %}<span class="badge">{{ l.flag }} {{ l.name }}</span>{% else %}<span class="muted">—</span>{% endfor %}</div></td><td>{{ r.video_codec }}</td><td>{{ r.size }}</td></tr>{% endfor %}</tbody></table></details>{% endfor %}
|
|
</div></section>{% endif %}
|
|
{% if sab_enabled %}<section id="downloads" class="view" data-source="downloads"><div class="stats"><span class="stat" id="downloadCount">Downloads werden geladen …</span><span class="stat" id="sabQueueStatus">SABnzbd …</span><span class="stat" id="sabSpeed">Geschwindigkeit: —</span></div><div id="downloadError" class="banner hidden"></div><div class="tablewrap"><table id="downloadsTable"><thead><tr><th class="col-title">Download</th><th class="col-state">Status</th><th class="col-progress">Fortschritt</th><th class="col-size">Größe / Rest</th><th class="col-time">Restzeit</th><th class="col-source">Quelle</th><th class="col-language">Sprache</th></tr></thead><tbody id="downloadsBody"><tr><td colspan="7" class="muted">Downloads werden geladen …</td></tr></tbody></table></div></section>{% endif %}
|
|
</main>
|
|
<script>
|
|
const rows=[...document.querySelectorAll('#movies tbody tr')], search=document.querySelector('#search'), audio=document.querySelector('#audioFilter'), subs=document.querySelector('#subFilter'), state=document.querySelector('#stateFilter'), count=document.querySelector('#visibleCount');
|
|
function apply(){const q=search.value.trim().toLowerCase();let n=0;rows.forEach(r=>{const ok=(!q||r.dataset.title.includes(q))&&(!audio.value||r.dataset.audio.includes(audio.value))&&(!subs.value||r.dataset.subs.includes(subs.value))&&(!state.value||r.dataset.state===state.value);r.classList.toggle('hidden',!ok);if(ok)n++});count.textContent=`${n} Filme`};[search,audio,subs,state].forEach(x=>x.addEventListener(x===search?'input':'change',apply));apply();
|
|
document.querySelectorAll('.nav button').forEach(b=>b.onclick=()=>{document.querySelectorAll('.nav button').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.view').forEach(x=>x.classList.remove('active'));b.classList.add('active');document.querySelector('#'+b.dataset.view).classList.add('active');document.querySelector('#rescan').dataset.source=b.dataset.view});
|
|
function statusText(s){if(s.state==='running')return `Scan: ${s.done}/${s.total||'?'} Dateien`;if(s.state==='done')return 'Scan abgeschlossen';if(s.state==='error')return 'Scanfehler: '+s.error;return 'Scan wird vorbereitet …'}
|
|
const seenRunning={radarr:false,sonarr:false};async function poll(){try{const data=await (await fetch('/api/scan-status')).json();document.querySelector('#radarrStatus').textContent=statusText(data.radarr);const sonarr=document.querySelector('#sonarrStatus');if(sonarr)sonarr.textContent=statusText(data.sonarr);['radarr','sonarr'].forEach(source=>{if(data[source].state==='running')seenRunning[source]=true;if(seenRunning[source]&&['done','error'].includes(data[source].state)){seenRunning[source]=false;setTimeout(()=>location.reload(),700)}})}catch(e){} };setInterval(poll,1200);poll();
|
|
document.querySelector('#rescan').onclick=async()=>{const b=document.querySelector('#rescan');b.disabled=true;b.textContent='Scanne …';await fetch('/api/rescan?source='+(b.dataset.source||'radarr'),{method:'POST'});b.disabled=false;b.textContent='↻ Neu scannen';poll()};
|
|
</script></body></html>
|
|
<style>
|
|
details.series{margin-bottom:8px;border:1px solid var(--line);border-radius:6px;overflow:hidden;background:#222}
|
|
details.series:last-child{margin-bottom:0}
|
|
</style>
|
|
<script>document.querySelectorAll('details.series').forEach(series => { series.open = false; });</script>
|
|
<style>
|
|
.subtitle-de{background:#126f39!important;border-color:#19894b!important;color:#d9ffe8!important}
|
|
.col-progress{width:16%}.col-time{width:10%}.col-source{width:10%}.col-language{width:12%}
|
|
.progress-wrap{display:flex;align-items:center;gap:8px}.progress-bar{height:7px;min-width:70px;flex:1;background:#3a3a3a;border-radius:99px;overflow:hidden}.progress-bar span{display:block;height:100%;background:#159447;border-radius:99px}.download-title{color:var(--blue);font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block}
|
|
</style>
|
|
<script>
|
|
document.querySelectorAll('#movies tbody tr td:nth-child(5) .badge,#sonarr tbody tr td:nth-child(5) .badge').forEach(badge=>{if(badge.textContent.includes('Deutsch'))badge.classList.add('subtitle-de')});
|
|
const downloadBody=document.querySelector('#downloadsBody');
|
|
function html(value){return String(value??'—').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
|
|
function renderDownloads(data){if(!downloadBody)return;const items=data.items||[];document.querySelector('#downloadCount').textContent=`${items.length} Downloads`;document.querySelector('#sabQueueStatus').textContent=`SABnzbd: ${data.paused?'Pausiert':data.queue_status||'Idle'}`;document.querySelector('#sabSpeed').textContent=`Geschwindigkeit: ${data.speed||'—'}`;const error=document.querySelector('#downloadError');error.classList.toggle('hidden',!data.error);error.innerHTML=data.error?`<strong>SABnzbd:</strong> ${html(data.error)}`:'';downloadBody.innerHTML=items.length?items.map(item=>{const pct=Math.max(0,Math.min(100,Number(item.percentage)||0));const languages=(item.languages||[]).map(l=>`<span class="badge">${html(l.flag)} ${html(l.name)}</span>`).join('')||'<span class="muted">Nicht erkannt</span>';return `<tr><td><span class="download-title" title="${html(item.title)}">${html(item.title)}</span><span class="muted">${html(item.category)}</span></td><td><span class="state ${item.completed?'ok':'neutral'}">${html(item.status)}</span></td><td><div class="progress-wrap"><div class="progress-bar"><span style="width:${pct}%"></span></div><span>${pct.toFixed(1)}%</span></div></td><td>${html(item.size)}<br><span class="muted">Rest: ${html(item.remaining)}</span></td><td>${html(item.completed?'—':item.timeleft)}</td><td>${html(item.source)}</td><td><div class="badges">${languages}</div></td></tr>`}).join(''):'<tr><td colspan="7" class="muted">Keine aktuellen Downloads</td></tr>'}
|
|
async function fetchDownloads(){if(!downloadBody)return;try{const response=await fetch('/api/downloads');const data=await response.json();renderDownloads(data)}catch(error){renderDownloads({error:'Dashboard konnte SABnzbd nicht erreichen.',items:[]})}};
|
|
if(downloadBody){fetchDownloads();setInterval(fetchDownloads,4000)}
|
|
</script>
|
|
<style>
|
|
.downloading-badge{background:#713c9e!important;border-color:#9257c2!important;color:#f2e5ff!important}
|
|
.downloading-state{background:#713c9e!important;color:#f2e5ff!important}
|
|
</style>
|
|
<script>
|
|
function normalizeDownloadText(value){return String(value||'').toLowerCase().replace(/[._()[\]{}'\-·]/g,' ').replace(/\s+/g,' ').trim()}
|
|
function markDownloading(items){
|
|
const downloads=(items||[]).filter(item=>!item.completed).map(item=>normalizeDownloadText(item.title));
|
|
document.querySelectorAll('#movies tbody tr,#sonarr tbody tr').forEach(row=>{
|
|
if(row.dataset.downloadMarked==='1'){
|
|
row.children[1].innerHTML='<span class="badge">Keine Datei</span>';
|
|
row.children[2].innerHTML='<span class="state bad">Fehler</span>';
|
|
row.dataset.downloadMarked='0';
|
|
}
|
|
const details=row.closest('details.series');
|
|
const seriesName=details?normalizeDownloadText(details.querySelector('summary')?.textContent.split('(')[0]):'';
|
|
const rowName=normalizeDownloadText(row.querySelector('.title')?.textContent);
|
|
if(!rowName||!downloads.some(download=>download.includes(rowName)||(seriesName&&download.includes(seriesName)&&download.includes(rowName.match(/s\d{2}e\d{2}/)?.[0]||''))))return;
|
|
const quality=row.children[1], state=row.children[2];
|
|
if(quality.textContent.includes('Keine Datei')||row.dataset.downloadMarked==='1'){
|
|
quality.innerHTML='<span class="badge downloading-badge">Downloading</span>';
|
|
state.innerHTML='<span class="state downloading-state">Downloading</span>';
|
|
row.dataset.downloadMarked='1';
|
|
}
|
|
});
|
|
}
|
|
const originalRenderDownloads=renderDownloads;
|
|
renderDownloads=function(data){originalRenderDownloads(data);markDownloading(data.items)};
|
|
</script>
|