feat: add category-based grouping for Sonarr series with anime and K-drama tag detection

Add tag fetching from Sonarr API and category_for() function to classify series based on normalized tags (anime, kdrama, standard). Parse tag labels/names into normalized strings removing non-alphanumeric characters for matching. Add category and category_label fields to series groups.

Update index route to build series_categories list grouping series by category key (anime/Anime, kdrama/K-Dramen, standard/Standard) and pass
This commit is contained in:
2026-08-15 12:37:58 +02:00
parent 2b8b0cd5e9
commit e49216dca5
2 changed files with 25 additions and 3 deletions
+23 -2
View File
@@ -372,7 +372,22 @@ def sonarr_groups():
groups = [] groups = []
queue = api_get(SONARR_URL, SONARR_API_KEY, "queue?includeUnknownSeriesItems=true") queue = api_get(SONARR_URL, SONARR_API_KEY, "queue?includeUnknownSeriesItems=true")
queued_episodes = {item.get("episodeId") for item in queue.get("records", [])} queued_episodes = {item.get("episodeId") for item in queue.get("records", [])}
tags = api_get(SONARR_URL, SONARR_API_KEY, "tag")
tag_names = {str(tag.get("id")): str(tag.get("label") or tag.get("name") or "").strip().casefold() for tag in tags}
def category_for(series):
normalized_tags = {
re.sub(r"[^a-z0-9]+", "", tag_names.get(str(tag_id), ""))
for tag_id in (series.get("tags") or [])
}
if "anime" in normalized_tags:
return "anime", "Anime"
if "kdrama" in normalized_tags:
return "kdrama", "K-Dramen"
return "standard", "Standard"
for series in api_get(SONARR_URL, SONARR_API_KEY, "series"): for series in api_get(SONARR_URL, SONARR_API_KEY, "series"):
category_key, category_label = category_for(series)
episodes = api_get(SONARR_URL, SONARR_API_KEY, f"episode?seriesId={series['id']}&includeSeries=false") episodes = api_get(SONARR_URL, SONARR_API_KEY, f"episode?seriesId={series['id']}&includeSeries=false")
files = {f["id"]: f for f in api_get(SONARR_URL, SONARR_API_KEY, f"episodefile?seriesId={series['id']}")} files = {f["id"]: f for f in api_get(SONARR_URL, SONARR_API_KEY, f"episodefile?seriesId={series['id']}")}
items = [] items = []
@@ -381,7 +396,8 @@ def sonarr_groups():
if ef: ef = dict(ef); ef["path"] = str(Path(series.get("path") or "") / ef.get("relativePath", "")) if ef: ef = dict(ef); ef["path"] = str(Path(series.get("path") or "") / ef.get("relativePath", ""))
label = f"S{episode.get('seasonNumber', 0):02d}E{episode.get('episodeNumber', 0):02d} · {episode.get('title') or ''}" label = f"S{episode.get('seasonNumber', 0):02d}E{episode.get('episodeNumber', 0):02d} · {episode.get('title') or ''}"
items.append(media_row(label, None, ef, SONARR_MEDIA_PATH, LOCAL_SERIES_PATH, {"episode":True, "season":episode.get("seasonNumber", 0), "downloading": episode.get("id") in queued_episodes})) items.append(media_row(label, None, ef, SONARR_MEDIA_PATH, LOCAL_SERIES_PATH, {"episode":True, "season":episode.get("seasonNumber", 0), "downloading": episode.get("id") in queued_episodes}))
groups.append({"title":series.get("title", ""), "year":series.get("year"), "episodes":items}) groups.append({"title":series.get("title", ""), "year":series.get("year"), "episodes":items,
"category":category_key, "category_label":category_label})
return sorted(groups, key=lambda x: x["title"].lower()) return sorted(groups, key=lambda x: x["title"].lower())
def start_scan(source, force=False): def start_scan(source, force=False):
@@ -425,6 +441,10 @@ def index():
episodes = [episode for episode in group["episodes"] if is_german_review_candidate(episode) and not has_german_track(episode)] episodes = [episode for episode in group["episodes"] if is_german_review_candidate(episode) and not has_german_track(episode)]
if episodes: if episodes:
missing_german_series.append({**group, "episodes": episodes}) missing_german_series.append({**group, "episodes": episodes})
series_categories = [
{"key": key, "label": label, "series": [group for group in series if group["category"] == key]}
for key, label in (("anime", "Anime"), ("kdrama", "K-Dramen"), ("standard", "Standard"))
]
dashboard_stats = { dashboard_stats = {
"movie_total": len(rows), "movie_total": len(rows),
"movie_files": sum(1 for row in rows if not row.get("missing")), "movie_files": sum(1 for row in rows if not row.get("missing")),
@@ -435,7 +455,8 @@ def index():
"episode_files": sum(1 for group in series for row in group["episodes"] if not row.get("missing")), "episode_files": sum(1 for group in series for row in group["episodes"] if not row.get("missing")),
"episode_missing": sum(1 for group in series for row in group["episodes"] if row.get("missing") and not row.get("downloading")), "episode_missing": sum(1 for group in series for row in group["episodes"] if row.get("missing") and not row.get("downloading")),
} }
return render_template("index.html", rows=rows, series=series, missing_german_movies=missing_german_movies, return render_template("index.html", rows=rows, series=series, series_categories=series_categories,
missing_german_movies=missing_german_movies,
missing_german_series=missing_german_series, error=error, missing_german_series=missing_german_series, error=error,
dashboard_stats=dashboard_stats, dashboard_stats=dashboard_stats,
sonarr_enabled=bool(SONARR_URL and SONARR_API_KEY), sab_enabled=bool(SAB_URL and SAB_API_KEY), sonarr_enabled=bool(SONARR_URL and SONARR_API_KEY), sab_enabled=bool(SAB_URL and SAB_API_KEY),
+2 -1
View File
@@ -22,7 +22,7 @@
{% 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 %} {% 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> </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"> {% 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 %} {% for category in series_categories %}{% if category.series %}<section class="series-category"><div class="series-category-heading"><h2>{{ category.label }}</h2><span>{{ category.series|length }} Serien</span></div>{% for group in category.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 %}</section>{% endif %}{% endfor %}
</div></section>{% endif %} </div></section>{% endif %}
<section id="missing" class="view" data-source="radarr"><div class="stats"><span class="stat" id="missingCount">{{ missing_german_movies|length + (missing_german_series|map(attribute='episodes')|map('length')|sum) }} Einträge ohne Deutsch</span><span class="stat scan">Vorhanden oder wird gerade geladen · Staffel 0 ausgeblendet</span></div><div class="tablewrap missing-table"> <section id="missing" class="view" data-source="radarr"><div class="stats"><span class="stat" id="missingCount">{{ missing_german_movies|length + (missing_german_series|map(attribute='episodes')|map('length')|sum) }} Einträge ohne Deutsch</span><span class="stat scan">Vorhanden oder wird gerade geladen · Staffel 0 ausgeblendet</span></div><div class="tablewrap missing-table">
<table id="missingMovies"><thead><tr><th class="col-title">Film</th><th class="col-quality">Qualität</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 missing_german_movies %}<tr><td><span class="title">{{ r.title }}</span><span class="year">{{ r.year or '' }}</span></td><td><span class="badge {% if not r.missing %}quality{% endif %}">{{ r.quality }}</span></td><td><span class="state bad">Ohne Deutsch</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> <table id="missingMovies"><thead><tr><th class="col-title">Film</th><th class="col-quality">Qualität</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 missing_german_movies %}<tr><td><span class="title">{{ r.title }}</span><span class="year">{{ r.year or '' }}</span></td><td><span class="badge {% if not r.missing %}quality{% endif %}">{{ r.quality }}</span></td><td><span class="state bad">Ohne Deutsch</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>
@@ -53,6 +53,7 @@ if('serviceWorker' in navigator)window.addEventListener('load',()=>navigator.ser
<style> <style>
details.series{margin-bottom:8px;border:1px solid var(--line);border-radius:6px;overflow:hidden;background:#222} details.series{margin-bottom:8px;border:1px solid var(--line);border-radius:6px;overflow:hidden;background:#222}
details.series:last-child{margin-bottom:0} details.series:last-child{margin-bottom:0}
.series-category{margin-bottom:16px}.series-category:last-child{margin-bottom:0}.series-category-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:11px 14px;margin-bottom:8px;border:1px solid rgba(96,169,230,.2);border-radius:9px;background:linear-gradient(100deg,rgba(44,55,70,.9),rgba(34,34,34,.9))}.series-category-heading h2{margin:0;color:#e9f2fb;font-size:15px}.series-category-heading span{color:#9da5b2;font-size:11px}
.nav-count{display:inline-block;min-width:18px;padding:1px 5px;margin-left:4px;border-radius:999px;background:#713030;color:#ffd9d9;font-size:10px}.empty-state{padding:28px;text-align:center;color:var(--muted)}.missing-table>table{border-bottom:1px solid var(--line)}.missing-series{border-radius:0!important;border-left:0!important;border-right:0!important;border-bottom:1px solid var(--line)!important}.missing-series:last-child{border-bottom:0!important} .nav-count{display:inline-block;min-width:18px;padding:1px 5px;margin-left:4px;border-radius:999px;background:#713030;color:#ffd9d9;font-size:10px}.empty-state{padding:28px;text-align:center;color:var(--muted)}.missing-table>table{border-bottom:1px solid var(--line)}.missing-series{border-radius:0!important;border-left:0!important;border-right:0!important;border-bottom:1px solid var(--line)!important}.missing-series:last-child{border-bottom:0!important}
</style> </style>
<script>document.querySelectorAll('details.series').forEach(series => { series.open = false; });</script> <script>document.querySelectorAll('details.series').forEach(series => { series.open = false; });</script>