Add a Flask-based dashboard for monitoring audio/subtitle languages in Radarr media files. Includes ffprobe integration for media analysis, SQLite caching, path mapping between Radarr and host filesystems, and a responsive web UI with filtering and statistics.
312 lines
7.6 KiB
HTML
312 lines
7.6 KiB
HTML
<!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>
|
|
<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>
|
|
|
|
<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>
|
|
</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>
|