feat: add Sonarr support with background scanning and progress tracking

Add Sonarr integration alongside existing Radarr functionality. Implement background ffprobe scanning with ThreadPoolExecutor, progress tracking API, and separate navigation for movies/series. Series are grouped by show with collapsible episodes. Reduce Gunicorn workers to 1, add scan status endpoint, and update README with Sonarr configuration instructions.
This commit is contained in:
nessi committed 2026-08-13 10:49:39 +02:00
1 parent 6ee9610b11
commit acbf3215bb
5 files changed
+169 -537

No files matched your search

+22 -306
View File
@@ -1,311 +1,27 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Radarr Language Dashboard</title>
<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:#202020;
--panel2:#262626;
--line:#373737;
--text:#dedede;
--muted:#929292;
--yellow:#f4c430;
--blue:#60a9e6;
--green:#159447;
--red:#bd4d4d;
--orange:#ba812d;
}
*{box-sizing:border-box}
body{
margin:0;
background:var(--bg);
color:var(--text);
font-family:Inter,system-ui,-apple-system,"Segoe UI",sans-serif;
font-size:13px;
}
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;font-size:14px}
.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}
button:hover{border-color:#777}
main{padding:14px 20px 30px}
.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;
}
.tablewrap{
border:1px solid #353535;
border-radius:6px;
overflow:hidden;
background:#202020;
}
table{
width:100%;
border-collapse:collapse;
table-layout:fixed;
}
thead th{
padding:8px 10px;
background:#282828;
border-bottom:1px solid #444;
color:#cfcfcf;
font-size:12px;
font-weight:650;
text-align:left;
position:sticky;
top:0;
}
tbody td{
padding:7px 10px;
border-bottom:1px solid #343434;
vertical-align:middle;
}
tbody tr:hover{background:#252525}
tbody tr:last-child td{border-bottom:0}
.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;
}
.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;
color:#fff;
}
.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)}
.error-icon{
color:#ff7777;
cursor:help;
font-size:12px;
margin-left:6px;
}
.banner{
padding:9px 11px;
border:1px solid #693b3b;
background:#372424;
border-radius:5px;
margin-bottom:10px;
color:#ffd1d1;
}
.hidden{display:none}
@media(max-width:1100px){
.toolbar{grid-template-columns:1fr 1fr}
.col-video,.col-size{display:none}
}
</style>
</head>
<body>
<header>
<div class="logo">RADARR</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>
: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 %}
<div class="toolbar">
<input id="search" type="search" placeholder="Film suchen …">
<select id="audioFilter">
<option value="">Alle Audio-Sprachen</option>
<option value="Deutsch">🇩🇪 Deutsch</option>
<option value="Englisch">🇬🇧 Englisch</option>
<option value="Japanisch">🇯🇵 Japanisch</option>
<option value="Koreanisch">🇰🇷 Koreanisch</option>
</select>
<select id="subFilter">
<option value="">Alle Untertitel</option>
<option value="Deutsch">🇩🇪 Deutsch</option>
<option value="Englisch">🇬🇧 Englisch</option>
<option value="Japanisch">🇯🇵 Japanisch</option>
<option value="Koreanisch">🇰🇷 Koreanisch</option>
</select>
<select id="stateFilter">
<option value="">Alle Status</option>
<option value="DE + EN">DE + EN</option>
<option value="Deutsch">Deutsch</option>
<option value="English only">English only</option>
</select>
</div>
<div class="stats">
<span class="stat" id="visibleCount"></span>
<span class="stat">Radarr: {{ radarr_media_path }}</span>
<span class="stat">Dashboard: {{ local_media_path }}</span>
</div>
<div class="tablewrap">
<table id="movies">
<thead>
<tr>
<th class="col-title">Movie</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 %}
<span class="error-icon" title="{{ r.error }}">⚠</span>
{% endif %}
</td>
<td>
{% if r.missing %}
<span class="badge">Keine Datei</span>
{% else %}
<span class="badge quality">{{ r.quality }}</span>
{% endif %}
</td>
<td><span class="state {{ r.state.class }}">{{ r.state.label }}</span></td>
<td>
<div class="badges">
{% for lang in r.audio_languages %}
<span class="badge">{{ lang.flag }} {{ lang.name }}</span>
{% else %}<span class="muted">—</span>{% endfor %}
</div>
</td>
<td>
<div class="badges">
{% for lang in r.subtitle_languages %}
<span class="badge">{{ lang.flag }} {{ lang.name }}</span>
{% else %}<span class="muted">—</span>{% endfor %}
</div>
</td>
<td class="col-video">{{ r.video_codec }}</td>
<td class="col-size">{{ r.size }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% 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 %}</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 %}
</main>
<script>
const rows=[...document.querySelectorAll('#movies tbody tr')];
const search=document.querySelector('#search');
const audio=document.querySelector('#audioFilter');
const subs=document.querySelector('#subFilter');
const state=document.querySelector('#stateFilter');
const count=document.querySelector('#visibleCount');
function apply(){
const q=search.value.trim().toLowerCase();
let visible=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) visible++;
});
count.textContent=`${visible} Filme`;
}
[search,audio,subs,state].forEach(x=>x.addEventListener(x===search?'input':'change',apply));
apply();
document.querySelector('#rescan').addEventListener('click',async()=>{
const b=document.querySelector('#rescan');
b.disabled=true;b.textContent='Scanne …';
try{
await fetch('/api/rescan',{method:'POST'});
location.reload();
}finally{b.disabled=false}
});
</script>
</body>
</html>
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>