1166 lines
42 KiB
Python
1166 lines
42 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
DC timeline synchronizer for Jellyfin + Seerr.
|
||
|
||
Features
|
||
--------
|
||
- Resolves TMDB IDs through the local Seerr API (no separate TMDB key required).
|
||
- Finds movies and series in Jellyfin by TMDB provider ID.
|
||
- Expands TV entries into individual Jellyfin episodes in season/episode order.
|
||
- Requests missing movies or TV seasons through Seerr.
|
||
- Creates or replaces chronologically ordered Jellyfin video playlists.
|
||
- Uses only the Python standard library.
|
||
|
||
Default profile: dc-shared
|
||
Timeline revision: 2026-08-03
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import difflib
|
||
import json
|
||
import os
|
||
import re
|
||
import ssl
|
||
import sys
|
||
import time
|
||
import unicodedata
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
from typing import Any, Iterable, Optional
|
||
from urllib.error import HTTPError, URLError
|
||
from urllib.parse import quote, urlencode
|
||
from urllib.request import Request, urlopen
|
||
|
||
VERSION = "2026.08.03-dc-r1"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Timeline data
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def movie(
|
||
title: str,
|
||
year: int,
|
||
tmdb_id: int | None = None,
|
||
*,
|
||
aliases: Iterable[str] = (),
|
||
) -> dict[str, Any]:
|
||
return {
|
||
"kind": "movie",
|
||
"title": title,
|
||
"year": year,
|
||
"tmdb_id": tmdb_id,
|
||
"aliases": list(aliases),
|
||
}
|
||
|
||
|
||
def tv(
|
||
title: str,
|
||
year: int,
|
||
seasons: Iterable[int],
|
||
tmdb_id: int | None = None,
|
||
*,
|
||
aliases: Iterable[str] = (),
|
||
) -> dict[str, Any]:
|
||
return {
|
||
"kind": "tv",
|
||
"title": title,
|
||
"year": year,
|
||
"seasons": list(seasons),
|
||
"tmdb_id": tmdb_id,
|
||
"aliases": list(aliases),
|
||
}
|
||
|
||
|
||
# DC has several separate screen continuities. Each list below is internally
|
||
# ordered as a practical story/viewing chronology. They are intentionally kept
|
||
# as separate Jellyfin playlists instead of pretending that every DC title is
|
||
# part of one MCU-style timeline.
|
||
|
||
DCEU_SNYDER_TIMELINE: list[dict[str, Any]] = [
|
||
movie("Wonder Woman", 2017, 297762),
|
||
movie("Wonder Woman 1984", 2020, 464052),
|
||
movie("Man of Steel", 2013, 49521),
|
||
movie("Batman v Superman: Dawn of Justice", 2016, 209112),
|
||
movie("Suicide Squad", 2016, 297761),
|
||
movie("Zack Snyder's Justice League", 2021, 791373, aliases=["Justice League Snyder Cut"]),
|
||
movie("Aquaman", 2018, 297802),
|
||
movie("Shazam!", 2019, 287947, aliases=["Shazam"]),
|
||
movie("Birds of Prey (and the Fantabulous Emancipation of One Harley Quinn)", 2020, 495764,
|
||
aliases=["Birds of Prey", "Harley Quinn: Birds of Prey"]),
|
||
movie("The Suicide Squad", 2021, 436969),
|
||
tv("Peacemaker", 2022, [1], 110492),
|
||
movie("Black Adam", 2022, 436270),
|
||
movie("Shazam! Fury of the Gods", 2023, 594767, aliases=["Shazam Fury of the Gods"]),
|
||
movie("The Flash", 2023, 298618),
|
||
movie("Blue Beetle", 2023, 565770),
|
||
movie("Aquaman and the Lost Kingdom", 2023, 572802),
|
||
]
|
||
|
||
DCEU_THEATRICAL_TIMELINE: list[dict[str, Any]] = [
|
||
*DCEU_SNYDER_TIMELINE[:5],
|
||
movie("Justice League", 2017, 141052),
|
||
*DCEU_SNYDER_TIMELINE[6:],
|
||
]
|
||
|
||
# New DC Universe. The Suicide Squad and Peacemaker S1 are included as bridge
|
||
# material: DC states that S1 happened nearly as shown, except for old-DCEU hero
|
||
# references and the Justice League cameo. Later unreleased projects are ordered
|
||
# by announced release, because their exact in-universe dates are not yet known.
|
||
DCU_TIMELINE: list[dict[str, Any]] = [
|
||
movie("The Suicide Squad", 2021, 436969),
|
||
tv("Peacemaker", 2022, [1], 110492),
|
||
tv("Creature Commandos", 2024, [1], 219543),
|
||
movie("Superman", 2025, 1061474),
|
||
tv("Peacemaker", 2022, [2], 110492),
|
||
movie("Supergirl", 2026, 1081003, aliases=["Supergirl: Woman of Tomorrow"]),
|
||
tv("Lanterns", 2026, [1], 95350),
|
||
movie("Clayface", 2026, 1400940),
|
||
]
|
||
|
||
REEVES_TIMELINE: list[dict[str, Any]] = [
|
||
movie("The Batman", 2022, 414906),
|
||
tv("The Penguin", 2024, [1], 194764),
|
||
]
|
||
|
||
NOLAN_TIMELINE: list[dict[str, Any]] = [
|
||
movie("Batman Begins", 2005, 272),
|
||
movie("The Dark Knight", 2008, 155),
|
||
movie("The Dark Knight Rises", 2012, 49026),
|
||
]
|
||
|
||
JOKER_TIMELINE: list[dict[str, Any]] = [
|
||
movie("Joker", 2019, 475557),
|
||
movie("Joker: Folie à Deux", 2024, 889737, aliases=["Joker Folie a Deux", "Joker 2"]),
|
||
]
|
||
|
||
BURTON_SCHUMACHER_TIMELINE: list[dict[str, Any]] = [
|
||
movie("Batman", 1989, 268),
|
||
movie("Batman Returns", 1992, 364),
|
||
movie("Batman Forever", 1995, 414),
|
||
movie("Batman & Robin", 1997, 415, aliases=["Batman and Robin"]),
|
||
]
|
||
|
||
SUPERMAN_CLASSIC_TIMELINE: list[dict[str, Any]] = [
|
||
movie("Superman", 1978, 192, aliases=["Superman: The Movie"]),
|
||
movie("Superman II", 1980, 8536),
|
||
movie("Superman III", 1983, 9531),
|
||
movie("Supergirl", 1984, 9651),
|
||
movie("Superman IV: The Quest for Peace", 1987, 11411),
|
||
]
|
||
|
||
@dataclass(frozen=True)
|
||
class TimelineEntry:
|
||
kind: str
|
||
title: str
|
||
year: int
|
||
tmdb_id: Optional[int] = None
|
||
seasons: tuple[int, ...] = ()
|
||
aliases: tuple[str, ...] = ()
|
||
|
||
@classmethod
|
||
def from_dict(cls, value: dict[str, Any]) -> "TimelineEntry":
|
||
return cls(
|
||
kind=str(value["kind"]),
|
||
title=str(value["title"]),
|
||
year=int(value["year"]),
|
||
tmdb_id=int(value["tmdb_id"]) if value.get("tmdb_id") is not None else None,
|
||
seasons=tuple(int(x) for x in value.get("seasons", [])),
|
||
aliases=tuple(str(x) for x in value.get("aliases", [])),
|
||
)
|
||
|
||
@property
|
||
def label(self) -> str:
|
||
if self.kind == "tv":
|
||
seasons = ", ".join(f"S{x}" for x in self.seasons)
|
||
return f"{self.title} ({self.year}) [{seasons}]"
|
||
return f"{self.title} ({self.year})"
|
||
|
||
|
||
@dataclass
|
||
class ResolvedEntry:
|
||
entry: TimelineEntry
|
||
tmdb_id: Optional[int] = None
|
||
jellyfin_ids: list[str] = field(default_factory=list)
|
||
missing_seasons: list[int] = field(default_factory=list)
|
||
local_episode_counts: dict[int, int] = field(default_factory=dict)
|
||
expected_episode_counts: dict[int, int] = field(default_factory=dict)
|
||
seerr_status: Optional[int] = None
|
||
note: str = ""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Utility functions
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class ApiError(RuntimeError):
|
||
def __init__(self, method: str, url: str, status: int, body: str):
|
||
self.method = method
|
||
self.url = url
|
||
self.status = status
|
||
self.body = body
|
||
super().__init__(f"{method} {url} -> HTTP {status}: {body[:500]}")
|
||
|
||
|
||
class HttpClient:
|
||
def __init__(
|
||
self,
|
||
base_url: str,
|
||
headers: Optional[dict[str, str]] = None,
|
||
timeout: int = 30,
|
||
verify_ssl: bool = True,
|
||
) -> None:
|
||
self.base_url = base_url.rstrip("/")
|
||
self.headers = headers or {}
|
||
self.timeout = timeout
|
||
self.ssl_context = None if verify_ssl else ssl._create_unverified_context() # noqa: SLF001
|
||
|
||
def request(
|
||
self,
|
||
method: str,
|
||
path: str,
|
||
*,
|
||
params: Optional[dict[str, Any]] = None,
|
||
json_body: Any = None,
|
||
expected: Iterable[int] = (200, 201, 204),
|
||
) -> Any:
|
||
url = f"{self.base_url}{path}"
|
||
if params:
|
||
clean_params: dict[str, Any] = {}
|
||
for key, value in params.items():
|
||
if value is None:
|
||
continue
|
||
if isinstance(value, bool):
|
||
clean_params[key] = "true" if value else "false"
|
||
elif isinstance(value, (list, tuple)):
|
||
clean_params[key] = ",".join(str(x) for x in value)
|
||
else:
|
||
clean_params[key] = value
|
||
# Seerr rejects form-style "+" encoding for spaces. Use strict
|
||
# RFC 3986 percent-encoding for every query-parameter value so
|
||
# reserved characters such as :, &, ?, #, apostrophes, *, / and
|
||
# non-ASCII characters are encoded consistently.
|
||
query = urlencode(clean_params, quote_via=quote, safe="")
|
||
if query:
|
||
url = f"{url}?{query}"
|
||
|
||
headers = {"Accept": "application/json", **self.headers}
|
||
data = None
|
||
if json_body is not None:
|
||
data = json.dumps(json_body, ensure_ascii=False).encode("utf-8")
|
||
headers["Content-Type"] = "application/json"
|
||
|
||
req = Request(url, data=data, headers=headers, method=method.upper())
|
||
try:
|
||
with urlopen(req, timeout=self.timeout, context=self.ssl_context) as response:
|
||
status = int(response.status)
|
||
raw = response.read()
|
||
except HTTPError as exc:
|
||
raw = exc.read()
|
||
body = raw.decode("utf-8", errors="replace")
|
||
raise ApiError(method.upper(), url, int(exc.code), body) from exc
|
||
except URLError as exc:
|
||
raise RuntimeError(f"Verbindung fehlgeschlagen: {method.upper()} {url}: {exc}") from exc
|
||
|
||
if status not in set(expected):
|
||
body = raw.decode("utf-8", errors="replace")
|
||
raise ApiError(method.upper(), url, status, body)
|
||
if not raw:
|
||
return None
|
||
text = raw.decode("utf-8", errors="replace")
|
||
try:
|
||
return json.loads(text)
|
||
except json.JSONDecodeError:
|
||
return text
|
||
|
||
def get(self, path: str, **kwargs: Any) -> Any:
|
||
return self.request("GET", path, **kwargs)
|
||
|
||
def post(self, path: str, **kwargs: Any) -> Any:
|
||
return self.request("POST", path, **kwargs)
|
||
|
||
def delete(self, path: str, **kwargs: Any) -> Any:
|
||
return self.request("DELETE", path, **kwargs)
|
||
|
||
|
||
def load_env(path: Path) -> None:
|
||
if not path.exists():
|
||
return
|
||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||
line = raw_line.strip()
|
||
if not line or line.startswith("#") or "=" not in line:
|
||
continue
|
||
key, value = line.split("=", 1)
|
||
key = key.strip()
|
||
value = value.strip()
|
||
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
|
||
value = value[1:-1]
|
||
os.environ.setdefault(key, value)
|
||
|
||
|
||
def env_bool(name: str, default: bool = True) -> bool:
|
||
value = os.environ.get(name)
|
||
if value is None:
|
||
return default
|
||
return value.strip().lower() not in {"0", "false", "no", "off"}
|
||
|
||
|
||
def normalize_title(value: str) -> str:
|
||
value = unicodedata.normalize("NFKD", value or "")
|
||
value = "".join(ch for ch in value if not unicodedata.combining(ch))
|
||
value = value.casefold().replace("&", " and ")
|
||
value = re.sub(r"\bmarvel(?:'s| studios)?\b", " ", value)
|
||
value = re.sub(r"[^a-z0-9]+", " ", value)
|
||
return " ".join(value.split())
|
||
|
||
|
||
def item_year(item: dict[str, Any]) -> Optional[int]:
|
||
year = item.get("ProductionYear")
|
||
if isinstance(year, int):
|
||
return year
|
||
date = item.get("PremiereDate") or item.get("ProductionYear")
|
||
if isinstance(date, str) and len(date) >= 4 and date[:4].isdigit():
|
||
return int(date[:4])
|
||
return None
|
||
|
||
|
||
def result_year(item: dict[str, Any]) -> Optional[int]:
|
||
for key in ("releaseDate", "firstAirDate"):
|
||
value = item.get(key)
|
||
if isinstance(value, str) and len(value) >= 4 and value[:4].isdigit():
|
||
return int(value[:4])
|
||
return None
|
||
|
||
|
||
def provider_tmdb_id(item: dict[str, Any]) -> Optional[int]:
|
||
providers = item.get("ProviderIds") or {}
|
||
for key, value in providers.items():
|
||
if str(key).casefold() == "tmdb" and str(value).isdigit():
|
||
return int(value)
|
||
return None
|
||
|
||
|
||
def compact_error(exc: Exception) -> str:
|
||
if isinstance(exc, ApiError):
|
||
body = exc.body.strip().replace("\n", " ")
|
||
try:
|
||
parsed = json.loads(exc.body)
|
||
body = str(parsed.get("message") or parsed.get("title") or parsed)
|
||
except (json.JSONDecodeError, AttributeError):
|
||
pass
|
||
return f"HTTP {exc.status}: {body[:240]}"
|
||
return str(exc)
|
||
|
||
|
||
def chunks(values: list[str], size: int) -> Iterable[list[str]]:
|
||
for index in range(0, len(values), size):
|
||
yield values[index : index + size]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Seerr integration
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class Seerr:
|
||
STATUS = {
|
||
1: "unbekannt",
|
||
2: "angefragt",
|
||
3: "in Bearbeitung",
|
||
4: "teilweise vorhanden",
|
||
5: "vorhanden",
|
||
6: "gelöscht",
|
||
}
|
||
|
||
def __init__(self, client: HttpClient, cache_file: Path) -> None:
|
||
self.client = client
|
||
self.cache_file = cache_file
|
||
self.cache: dict[str, int] = {}
|
||
if cache_file.exists():
|
||
try:
|
||
raw = json.loads(cache_file.read_text(encoding="utf-8"))
|
||
self.cache = {str(k): int(v) for k, v in raw.items()}
|
||
except (OSError, ValueError, TypeError, json.JSONDecodeError):
|
||
self.cache = {}
|
||
|
||
def save_cache(self) -> None:
|
||
self.cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||
self.cache_file.write_text(
|
||
json.dumps(self.cache, indent=2, ensure_ascii=False, sort_keys=True) + "\n",
|
||
encoding="utf-8",
|
||
)
|
||
|
||
@staticmethod
|
||
def cache_key(entry: TimelineEntry) -> str:
|
||
return f"{entry.kind}:{entry.year}:{normalize_title(entry.title)}"
|
||
|
||
def resolve_tmdb(self, entry: TimelineEntry) -> Optional[int]:
|
||
key = self.cache_key(entry)
|
||
if entry.tmdb_id is not None:
|
||
self.cache[key] = entry.tmdb_id
|
||
return entry.tmdb_id
|
||
if key in self.cache:
|
||
return self.cache[key]
|
||
|
||
queries = [entry.title, *entry.aliases]
|
||
candidates: list[tuple[float, dict[str, Any]]] = []
|
||
wanted_names = {normalize_title(name) for name in queries}
|
||
|
||
for query in queries:
|
||
payload = self.client.get(
|
||
"/api/v1/search",
|
||
params={"query": query, "page": 1, "language": "en"},
|
||
)
|
||
for result in payload.get("results", []) if isinstance(payload, dict) else []:
|
||
if result.get("mediaType") != entry.kind:
|
||
continue
|
||
names = [
|
||
result.get("title"),
|
||
result.get("originalTitle"),
|
||
result.get("name"),
|
||
result.get("originalName"),
|
||
]
|
||
normalized = [normalize_title(str(name)) for name in names if name]
|
||
if not normalized:
|
||
continue
|
||
|
||
best_name_score = max(
|
||
difflib.SequenceMatcher(None, wanted, candidate).ratio()
|
||
for wanted in wanted_names
|
||
for candidate in normalized
|
||
)
|
||
year = result_year(result)
|
||
year_delta = abs(year - entry.year) if year is not None else 9
|
||
year_score = 1.0 if year_delta == 0 else 0.75 if year_delta == 1 else 0.0
|
||
exact_bonus = 0.25 if any(name in wanted_names for name in normalized) else 0.0
|
||
score = best_name_score * 0.75 + year_score * 0.25 + exact_bonus
|
||
candidates.append((score, result))
|
||
|
||
if candidates and max(score for score, _ in candidates) >= 1.10:
|
||
break
|
||
|
||
if not candidates:
|
||
return None
|
||
|
||
candidates.sort(key=lambda pair: pair[0], reverse=True)
|
||
best_score, best = candidates[0]
|
||
best_year = result_year(best)
|
||
if best_score < 0.82:
|
||
return None
|
||
if best_year is not None and abs(best_year - entry.year) > 1:
|
||
return None
|
||
|
||
tmdb_id = best.get("id")
|
||
if not isinstance(tmdb_id, int):
|
||
return None
|
||
self.cache[key] = tmdb_id
|
||
return tmdb_id
|
||
|
||
def details(self, entry: TimelineEntry, tmdb_id: int) -> dict[str, Any]:
|
||
path = f"/api/v1/{'movie' if entry.kind == 'movie' else 'tv'}/{tmdb_id}"
|
||
payload = self.client.get(path)
|
||
return payload if isinstance(payload, dict) else {}
|
||
|
||
@staticmethod
|
||
def media_status(details: dict[str, Any]) -> Optional[int]:
|
||
media_info = details.get("mediaInfo") or {}
|
||
value = media_info.get("status")
|
||
try:
|
||
return int(value)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
@staticmethod
|
||
def expected_season_counts(details: dict[str, Any], wanted: Iterable[int]) -> dict[int, int]:
|
||
wanted_set = set(wanted)
|
||
counts: dict[int, int] = {}
|
||
for season in details.get("seasons", []) or []:
|
||
try:
|
||
number = int(season.get("seasonNumber"))
|
||
count = int(season.get("episodeCount"))
|
||
except (TypeError, ValueError, AttributeError):
|
||
continue
|
||
if number in wanted_set and count >= 0:
|
||
counts[number] = count
|
||
return counts
|
||
|
||
@staticmethod
|
||
def requested_seasons(details: dict[str, Any]) -> set[int]:
|
||
media_info = details.get("mediaInfo") or {}
|
||
requested: set[int] = set()
|
||
for req in media_info.get("requests", []) or []:
|
||
seasons = req.get("seasons") or []
|
||
for season in seasons:
|
||
if isinstance(season, int):
|
||
requested.add(season)
|
||
continue
|
||
if isinstance(season, dict):
|
||
for key in ("seasonNumber", "number"):
|
||
try:
|
||
requested.add(int(season[key]))
|
||
break
|
||
except (KeyError, TypeError, ValueError):
|
||
continue
|
||
return requested
|
||
|
||
def request_movie(self, tmdb_id: int) -> dict[str, Any]:
|
||
payload = {"mediaType": "movie", "mediaId": tmdb_id, "is4k": False}
|
||
result = self.client.post("/api/v1/request", json_body=payload)
|
||
return result if isinstance(result, dict) else {}
|
||
|
||
def request_tv(self, tmdb_id: int, seasons: list[int]) -> dict[str, Any]:
|
||
payload = {
|
||
"mediaType": "tv",
|
||
"mediaId": tmdb_id,
|
||
"seasons": seasons,
|
||
"is4k": False,
|
||
}
|
||
result = self.client.post("/api/v1/request", json_body=payload)
|
||
return result if isinstance(result, dict) else {}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Jellyfin integration
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class Jellyfin:
|
||
def __init__(self, client: HttpClient, username: Optional[str], user_id: Optional[str]) -> None:
|
||
self.client = client
|
||
self.username = username
|
||
self.user_id = user_id
|
||
|
||
def resolve_user(self) -> str:
|
||
if self.user_id:
|
||
return self.user_id
|
||
users = self.client.get("/Users")
|
||
if not isinstance(users, list):
|
||
raise RuntimeError("Jellyfin /Users hat keine Benutzerliste zurückgegeben.")
|
||
if self.username:
|
||
for user in users:
|
||
if str(user.get("Name", "")).casefold() == self.username.casefold():
|
||
self.user_id = str(user["Id"])
|
||
return self.user_id
|
||
available = ", ".join(str(user.get("Name")) for user in users)
|
||
raise RuntimeError(f"Jellyfin-Benutzer '{self.username}' nicht gefunden. Vorhanden: {available}")
|
||
if len(users) == 1:
|
||
self.user_id = str(users[0]["Id"])
|
||
return self.user_id
|
||
available = ", ".join(str(user.get("Name")) for user in users)
|
||
raise RuntimeError(
|
||
"Mehrere Jellyfin-Benutzer gefunden. Setze JELLYFIN_USERNAME oder "
|
||
f"JELLYFIN_USER_ID. Vorhanden: {available}"
|
||
)
|
||
|
||
def paged_user_items(self, **params: Any) -> list[dict[str, Any]]:
|
||
user_id = self.resolve_user()
|
||
result: list[dict[str, Any]] = []
|
||
start = 0
|
||
limit = 500
|
||
while True:
|
||
query = {**params, "StartIndex": start, "Limit": limit}
|
||
payload = self.client.get(f"/Users/{user_id}/Items", params=query)
|
||
if not isinstance(payload, dict):
|
||
break
|
||
items = payload.get("Items", []) or []
|
||
result.extend(items)
|
||
total = int(payload.get("TotalRecordCount", len(result)))
|
||
start += len(items)
|
||
if not items or start >= total:
|
||
break
|
||
return result
|
||
|
||
def movies(self) -> list[dict[str, Any]]:
|
||
return self.paged_user_items(
|
||
Recursive=True,
|
||
IncludeItemTypes="Movie",
|
||
Fields="ProviderIds,ProductionYear,PremiereDate,OriginalTitle,Path",
|
||
EnableImages=False,
|
||
)
|
||
|
||
def series(self) -> list[dict[str, Any]]:
|
||
return self.paged_user_items(
|
||
Recursive=True,
|
||
IncludeItemTypes="Series",
|
||
Fields="ProviderIds,ProductionYear,PremiereDate,OriginalTitle,Path",
|
||
EnableImages=False,
|
||
)
|
||
|
||
def episodes(self, series_id: str, seasons: Iterable[int]) -> list[dict[str, Any]]:
|
||
wanted = set(seasons)
|
||
items = self.paged_user_items(
|
||
ParentId=series_id,
|
||
Recursive=True,
|
||
IncludeItemTypes="Episode",
|
||
Fields="ProviderIds,ParentIndexNumber,IndexNumber,PremiereDate,Path,SeriesName",
|
||
EnableImages=False,
|
||
)
|
||
filtered = []
|
||
for item in items:
|
||
try:
|
||
season = int(item.get("ParentIndexNumber"))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if season in wanted:
|
||
filtered.append(item)
|
||
filtered.sort(
|
||
key=lambda item: (
|
||
int(item.get("ParentIndexNumber") or 0),
|
||
int(item.get("IndexNumber") or 0),
|
||
str(item.get("SortName") or item.get("Name") or ""),
|
||
)
|
||
)
|
||
return filtered
|
||
|
||
@staticmethod
|
||
def match_item(
|
||
items: list[dict[str, Any]],
|
||
entry: TimelineEntry,
|
||
tmdb_id: Optional[int],
|
||
) -> Optional[dict[str, Any]]:
|
||
if tmdb_id is not None:
|
||
for item in items:
|
||
if provider_tmdb_id(item) == tmdb_id:
|
||
return item
|
||
|
||
wanted = {normalize_title(entry.title), *(normalize_title(x) for x in entry.aliases)}
|
||
candidates: list[tuple[float, dict[str, Any]]] = []
|
||
for item in items:
|
||
names = [item.get("Name"), item.get("OriginalTitle")]
|
||
names_normalized = [normalize_title(str(x)) for x in names if x]
|
||
if not names_normalized:
|
||
continue
|
||
title_score = max(
|
||
difflib.SequenceMatcher(None, target, candidate).ratio()
|
||
for target in wanted
|
||
for candidate in names_normalized
|
||
)
|
||
year = item_year(item)
|
||
year_score = 1.0 if year == entry.year else 0.7 if year and abs(year - entry.year) == 1 else 0.0
|
||
score = title_score * 0.8 + year_score * 0.2
|
||
if score >= 0.82:
|
||
candidates.append((score, item))
|
||
if not candidates:
|
||
return None
|
||
candidates.sort(key=lambda pair: pair[0], reverse=True)
|
||
return candidates[0][1]
|
||
|
||
def find_playlist(self, name: str) -> Optional[dict[str, Any]]:
|
||
playlists = self.paged_user_items(
|
||
Recursive=True,
|
||
IncludeItemTypes="Playlist",
|
||
Fields="Path",
|
||
EnableImages=False,
|
||
)
|
||
for playlist in playlists:
|
||
if str(playlist.get("Name", "")).casefold() == name.casefold():
|
||
return playlist
|
||
return None
|
||
|
||
def playlist_items(self, playlist_id: str) -> list[dict[str, Any]]:
|
||
payload = self.client.get(
|
||
f"/Playlists/{playlist_id}/Items",
|
||
params={"UserId": self.resolve_user(), "Fields": "ProviderIds"},
|
||
)
|
||
if not isinstance(payload, dict):
|
||
return []
|
||
return list(payload.get("Items", []) or [])
|
||
|
||
def create_playlist(self, name: str, item_ids: list[str]) -> dict[str, Any]:
|
||
body = {
|
||
"Name": name,
|
||
"Ids": item_ids,
|
||
"UserId": self.resolve_user(),
|
||
"MediaType": "Video",
|
||
"IsPublic": env_bool("JELLYFIN_PLAYLIST_PUBLIC", True),
|
||
}
|
||
result = self.client.post("/Playlists", json_body=body)
|
||
return result if isinstance(result, dict) else {}
|
||
|
||
def clear_playlist(self, playlist_id: str) -> None:
|
||
existing = self.playlist_items(playlist_id)
|
||
if not existing:
|
||
return
|
||
entry_ids = [
|
||
str(item.get("PlaylistItemId") or item.get("Id"))
|
||
for item in existing
|
||
if item.get("PlaylistItemId") or item.get("Id")
|
||
]
|
||
for group in chunks(entry_ids, 100):
|
||
self.client.delete(
|
||
f"/Playlists/{playlist_id}/Items",
|
||
params={"EntryIds": group},
|
||
)
|
||
# Some older Jellyfin versions returned 204 without deleting. Verify.
|
||
if self.playlist_items(playlist_id):
|
||
raise RuntimeError("Jellyfin meldet Erfolg, aber die Playlist ist nach dem Leeren nicht leer.")
|
||
|
||
def add_playlist_items(self, playlist_id: str, item_ids: list[str]) -> None:
|
||
for group in chunks(item_ids, 100):
|
||
self.client.post(
|
||
f"/Playlists/{playlist_id}/Items",
|
||
params={"Ids": group, "UserId": self.resolve_user()},
|
||
)
|
||
|
||
def delete_playlist(self, playlist_id: str) -> None:
|
||
self.client.delete(f"/Items/{playlist_id}")
|
||
|
||
def sync_playlist(self, name: str, item_ids: list[str]) -> str:
|
||
if not item_ids:
|
||
raise RuntimeError("Keine verfügbaren Jellyfin-Medien für die Playlist gefunden.")
|
||
|
||
existing = self.find_playlist(name)
|
||
if existing:
|
||
playlist_id = str(existing["Id"])
|
||
old_ids = [str(item.get("Id")) for item in self.playlist_items(playlist_id)]
|
||
if old_ids == item_ids:
|
||
return "unverändert"
|
||
try:
|
||
self.clear_playlist(playlist_id)
|
||
self.add_playlist_items(playlist_id, item_ids)
|
||
return "aktualisiert"
|
||
except Exception as clear_error:
|
||
# Fallback for known playlist-removal quirks in some Jellyfin releases.
|
||
try:
|
||
self.delete_playlist(playlist_id)
|
||
self.create_playlist(name, item_ids)
|
||
return f"neu erstellt (Fallback nach: {compact_error(clear_error)})"
|
||
except Exception as recreate_error:
|
||
raise RuntimeError(
|
||
"Playlist konnte weder aktualisiert noch neu erstellt werden. "
|
||
f"Update: {compact_error(clear_error)}; Neu: {compact_error(recreate_error)}"
|
||
) from recreate_error
|
||
|
||
self.create_playlist(name, item_ids)
|
||
return "erstellt"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Orchestration
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@dataclass
|
||
class Profile:
|
||
key: str
|
||
playlist_name: str
|
||
entries: list[TimelineEntry]
|
||
|
||
|
||
def build_profiles(profile_name: str) -> list[Profile]:
|
||
timelines = {
|
||
"dceu-snyder": (
|
||
os.environ.get("JELLYFIN_PLAYLIST_DCEU_SNYDER", "DC – DCEU chronologisch (Snyder Cut)"),
|
||
DCEU_SNYDER_TIMELINE,
|
||
),
|
||
"dceu-theatrical": (
|
||
os.environ.get("JELLYFIN_PLAYLIST_DCEU_THEATRICAL", "DC – DCEU chronologisch (Justice League 2017)"),
|
||
DCEU_THEATRICAL_TIMELINE,
|
||
),
|
||
"dcu": (
|
||
os.environ.get("JELLYFIN_PLAYLIST_DCU", "DC – DCU chronologisch"),
|
||
DCU_TIMELINE,
|
||
),
|
||
"reeves": (
|
||
os.environ.get("JELLYFIN_PLAYLIST_REEVES", "DC – The Batman Epic Crime Saga"),
|
||
REEVES_TIMELINE,
|
||
),
|
||
"nolan": (
|
||
os.environ.get("JELLYFIN_PLAYLIST_NOLAN", "DC – The Dark Knight Trilogie"),
|
||
NOLAN_TIMELINE,
|
||
),
|
||
"joker": (
|
||
os.environ.get("JELLYFIN_PLAYLIST_JOKER", "DC – Joker Elseworlds"),
|
||
JOKER_TIMELINE,
|
||
),
|
||
"burton-schumacher": (
|
||
os.environ.get("JELLYFIN_PLAYLIST_BURTON", "DC – Batman Burton und Schumacher"),
|
||
BURTON_SCHUMACHER_TIMELINE,
|
||
),
|
||
"superman-classic": (
|
||
os.environ.get("JELLYFIN_PLAYLIST_SUPERMAN_CLASSIC", "DC – Superman klassische Filmreihe"),
|
||
SUPERMAN_CLASSIC_TIMELINE,
|
||
),
|
||
}
|
||
|
||
def make(key: str) -> Profile:
|
||
playlist_name, raw_entries = timelines[key]
|
||
return Profile(key, playlist_name, [TimelineEntry.from_dict(x) for x in raw_entries])
|
||
|
||
if profile_name in timelines:
|
||
return [make(profile_name)]
|
||
if profile_name == "dc-shared":
|
||
return [make("dceu-snyder"), make("dcu")]
|
||
if profile_name in {"dc-complete", "all"}:
|
||
# Snyder Cut is the default DCEU branch. The theatrical Justice League
|
||
# profile remains separately selectable to avoid duplicate continuities.
|
||
return [make(key) for key in (
|
||
"dceu-snyder", "dcu", "reeves", "nolan", "joker",
|
||
"burton-schumacher", "superman-classic",
|
||
)]
|
||
raise ValueError(f"Unbekanntes Profil: {profile_name}")
|
||
|
||
|
||
def inspect_profile(
|
||
profile: Profile,
|
||
jellyfin: Jellyfin,
|
||
seerr: Seerr,
|
||
movies: list[dict[str, Any]],
|
||
series: list[dict[str, Any]],
|
||
delay: float,
|
||
) -> list[ResolvedEntry]:
|
||
resolved: list[ResolvedEntry] = []
|
||
for index, entry in enumerate(profile.entries, start=1):
|
||
print(f"[{index:02d}/{len(profile.entries):02d}] Prüfe {entry.label}")
|
||
result = ResolvedEntry(entry=entry)
|
||
try:
|
||
result.tmdb_id = seerr.resolve_tmdb(entry)
|
||
except Exception as exc:
|
||
result.note = f"Seerr-Suche fehlgeschlagen: {compact_error(exc)}"
|
||
resolved.append(result)
|
||
print(f" ! {result.note}")
|
||
continue
|
||
|
||
if result.tmdb_id is None:
|
||
result.note = "Kein eindeutiger TMDB-Treffer über Seerr"
|
||
resolved.append(result)
|
||
print(f" ! {result.note}")
|
||
continue
|
||
|
||
try:
|
||
details = seerr.details(entry, result.tmdb_id)
|
||
result.seerr_status = seerr.media_status(details)
|
||
except Exception as exc:
|
||
details = {}
|
||
result.note = f"Seerr-Details fehlgeschlagen: {compact_error(exc)}"
|
||
|
||
if entry.kind == "movie":
|
||
local = jellyfin.match_item(movies, entry, result.tmdb_id)
|
||
if local:
|
||
result.jellyfin_ids = [str(local["Id"])]
|
||
print(f" + Jellyfin: vorhanden | TMDB {result.tmdb_id}")
|
||
else:
|
||
status_text = Seerr.STATUS.get(result.seerr_status or 0, "nicht vorhanden")
|
||
print(f" - Jellyfin: fehlt | Seerr: {status_text} | TMDB {result.tmdb_id}")
|
||
else:
|
||
local_series = jellyfin.match_item(series, entry, result.tmdb_id)
|
||
result.expected_episode_counts = seerr.expected_season_counts(details, entry.seasons)
|
||
if local_series:
|
||
episodes = jellyfin.episodes(str(local_series["Id"]), entry.seasons)
|
||
counts: dict[int, int] = {season: 0 for season in entry.seasons}
|
||
for episode in episodes:
|
||
season = int(episode.get("ParentIndexNumber") or 0)
|
||
counts[season] = counts.get(season, 0) + 1
|
||
result.local_episode_counts = counts
|
||
result.jellyfin_ids = [str(episode["Id"]) for episode in episodes]
|
||
for season in entry.seasons:
|
||
local_count = counts.get(season, 0)
|
||
expected = result.expected_episode_counts.get(season)
|
||
if local_count == 0 or (expected is not None and local_count < expected):
|
||
result.missing_seasons.append(season)
|
||
count_text = ", ".join(
|
||
f"S{season}: {counts.get(season, 0)}/{result.expected_episode_counts.get(season, '?')}"
|
||
for season in entry.seasons
|
||
)
|
||
if result.missing_seasons:
|
||
print(f" ~ Jellyfin: unvollständig ({count_text}) | TMDB {result.tmdb_id}")
|
||
else:
|
||
print(f" + Jellyfin: vorhanden ({count_text}) | TMDB {result.tmdb_id}")
|
||
else:
|
||
result.missing_seasons = list(entry.seasons)
|
||
status_text = Seerr.STATUS.get(result.seerr_status or 0, "nicht vorhanden")
|
||
print(
|
||
f" - Jellyfin: Serie fehlt (S{','.join(map(str, entry.seasons))}) "
|
||
f"| Seerr: {status_text} | TMDB {result.tmdb_id}"
|
||
)
|
||
|
||
resolved.append(result)
|
||
if delay > 0:
|
||
time.sleep(delay)
|
||
|
||
seerr.save_cache()
|
||
return resolved
|
||
|
||
|
||
def request_missing(resolved: list[ResolvedEntry], seerr: Seerr, dry_run: bool) -> tuple[int, int, int]:
|
||
requested = 0
|
||
skipped = 0
|
||
failed = 0
|
||
print("\n=== Seerr-Requests ===")
|
||
|
||
for result in resolved:
|
||
entry = result.entry
|
||
if result.tmdb_id is None:
|
||
print(f"[FEHLER] {entry.label}: keine TMDB-ID")
|
||
failed += 1
|
||
continue
|
||
|
||
if entry.kind == "movie":
|
||
if result.jellyfin_ids:
|
||
continue
|
||
if result.seerr_status in {2, 3, 5}:
|
||
print(
|
||
f"[SKIP] {entry.label}: Seerr-Status "
|
||
f"{Seerr.STATUS.get(result.seerr_status, result.seerr_status)}"
|
||
)
|
||
skipped += 1
|
||
continue
|
||
if dry_run:
|
||
print(f"[DRY] Film anfragen: {entry.label} (TMDB {result.tmdb_id})")
|
||
requested += 1
|
||
continue
|
||
try:
|
||
seerr.request_movie(result.tmdb_id)
|
||
print(f"[REQUEST] {entry.label}")
|
||
requested += 1
|
||
except Exception as exc:
|
||
message = compact_error(exc)
|
||
if isinstance(exc, ApiError) and exc.status in {202, 400, 409}:
|
||
print(f"[SKIP] {entry.label}: {message}")
|
||
skipped += 1
|
||
else:
|
||
print(f"[FEHLER] {entry.label}: {message}")
|
||
failed += 1
|
||
continue
|
||
|
||
missing = list(result.missing_seasons)
|
||
if not missing:
|
||
continue
|
||
|
||
# Avoid duplicate season requests where Seerr exposes them in mediaInfo.
|
||
try:
|
||
details = seerr.details(entry, result.tmdb_id)
|
||
already = seerr.requested_seasons(details)
|
||
status = seerr.media_status(details)
|
||
except Exception:
|
||
already = set()
|
||
status = result.seerr_status
|
||
|
||
if status in {2, 3} and not already:
|
||
print(
|
||
f"[SKIP] {entry.label}: Serie bereits "
|
||
f"{Seerr.STATUS.get(status, status)}"
|
||
)
|
||
skipped += 1
|
||
continue
|
||
|
||
wanted = [season for season in missing if season not in already]
|
||
if not wanted:
|
||
print(f"[SKIP] {entry.title}: fehlende Staffeln bereits angefragt")
|
||
skipped += 1
|
||
continue
|
||
|
||
if dry_run:
|
||
print(f"[DRY] Serie anfragen: {entry.title}, Staffeln {wanted} (TMDB {result.tmdb_id})")
|
||
requested += 1
|
||
continue
|
||
try:
|
||
seerr.request_tv(result.tmdb_id, wanted)
|
||
print(f"[REQUEST] {entry.title}: Staffeln {wanted}")
|
||
requested += 1
|
||
except Exception as exc:
|
||
message = compact_error(exc)
|
||
if isinstance(exc, ApiError) and exc.status in {202, 400, 409}:
|
||
print(f"[SKIP] {entry.title}: {message}")
|
||
skipped += 1
|
||
else:
|
||
print(f"[FEHLER] {entry.title}: {message}")
|
||
failed += 1
|
||
|
||
return requested, skipped, failed
|
||
|
||
|
||
def sync_playlist(profile: Profile, resolved: list[ResolvedEntry], jellyfin: Jellyfin, dry_run: bool) -> tuple[int, int]:
|
||
item_ids: list[str] = []
|
||
missing_entries = 0
|
||
for result in resolved:
|
||
item_ids.extend(result.jellyfin_ids)
|
||
if result.entry.kind == "movie" and not result.jellyfin_ids:
|
||
missing_entries += 1
|
||
elif result.entry.kind == "tv" and result.missing_seasons:
|
||
missing_entries += 1
|
||
|
||
# Preserve order but remove accidental duplicate IDs.
|
||
seen: set[str] = set()
|
||
ordered_ids: list[str] = []
|
||
for item_id in item_ids:
|
||
if item_id not in seen:
|
||
seen.add(item_id)
|
||
ordered_ids.append(item_id)
|
||
|
||
print("\n=== Jellyfin-Playlist ===")
|
||
print(f"Name: {profile.playlist_name}")
|
||
print(f"Einträge: {len(ordered_ids)} Jellyfin-Videos")
|
||
print(f"Unvollst.: {missing_entries} Timeline-Blöcke")
|
||
|
||
if dry_run:
|
||
print("[DRY] Playlist wird nicht verändert.")
|
||
return len(ordered_ids), missing_entries
|
||
|
||
try:
|
||
state = jellyfin.sync_playlist(profile.playlist_name, ordered_ids)
|
||
print(f"[OK] Playlist {state}.")
|
||
except Exception as exc:
|
||
message = compact_error(exc)
|
||
if "parentFolder" in message:
|
||
message += (
|
||
" | Bekannter Jellyfin-Fehler: Prüfe, ob im Jellyfin-Datenverzeichnis "
|
||
"der Ordner 'Playlists' existiert und dem Jellyfin-Benutzer gehört."
|
||
)
|
||
raise RuntimeError(message) from exc
|
||
return len(ordered_ids), missing_entries
|
||
|
||
|
||
def print_profile(profile: Profile) -> None:
|
||
print(f"\n# {profile.playlist_name} ({len(profile.entries)} Blöcke)")
|
||
for index, entry in enumerate(profile.entries, start=1):
|
||
print(f"{index:02d}. {entry.label}")
|
||
|
||
|
||
def parse_args() -> argparse.Namespace:
|
||
parser = argparse.ArgumentParser(
|
||
description="DC-Timelines in Jellyfin synchronisieren und fehlende Medien über Seerr anfragen.",
|
||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||
)
|
||
parser.add_argument("--version", action="version", version=f"%(prog)s {VERSION}")
|
||
parser.add_argument(
|
||
"--env-file",
|
||
default="dc.env",
|
||
help="Konfigurationsdatei im KEY=VALUE-Format",
|
||
)
|
||
parser.add_argument(
|
||
"--profile",
|
||
choices=["dc-shared", "dceu-snyder", "dceu-theatrical", "dcu", "reeves", "nolan", "joker", "burton-schumacher", "superman-classic", "dc-complete", "all"],
|
||
default="dc-shared",
|
||
help="Welche Playlist(s) verarbeitet werden",
|
||
)
|
||
parser.add_argument("--list", action="store_true", help="Timeline nur ausgeben")
|
||
parser.add_argument("--dry-run", action="store_true", help="Nur prüfen; nichts anfragen oder ändern")
|
||
parser.add_argument("--request-missing", action="store_true", help="Fehlende Medien über Seerr anfragen")
|
||
parser.add_argument("--sync-playlist", action="store_true", help="Jellyfin-Playlist erstellen/aktualisieren")
|
||
parser.add_argument(
|
||
"--cache-file",
|
||
default=".cache/dc-playlist/tmdb.json",
|
||
help="Lokaler Cache für über Seerr aufgelöste TMDB-IDs",
|
||
)
|
||
parser.add_argument(
|
||
"--search-delay",
|
||
type=float,
|
||
default=0.05,
|
||
help="Pause zwischen Timeline-Einträgen in Sekunden",
|
||
)
|
||
return parser.parse_args()
|
||
|
||
|
||
def required_env(name: str) -> str:
|
||
value = os.environ.get(name, "").strip()
|
||
if not value:
|
||
raise RuntimeError(f"Pflichtwert fehlt: {name}")
|
||
return value
|
||
|
||
|
||
def main() -> int:
|
||
args = parse_args()
|
||
load_env(Path(args.env_file).expanduser())
|
||
profiles = build_profiles(args.profile)
|
||
|
||
if args.list:
|
||
for profile in profiles:
|
||
print_profile(profile)
|
||
return 0
|
||
|
||
# With no write action, behave as a useful status-only dry run.
|
||
dry_run = args.dry_run or not (args.request_missing or args.sync_playlist)
|
||
|
||
try:
|
||
jellyfin_url = required_env("JELLYFIN_URL")
|
||
jellyfin_api_key = required_env("JELLYFIN_API_KEY")
|
||
seerr_url = required_env("SEERR_URL")
|
||
seerr_api_key = required_env("SEERR_API_KEY")
|
||
except RuntimeError as exc:
|
||
print(f"Konfigurationsfehler: {exc}", file=sys.stderr)
|
||
print(f"Lege '{args.env_file}' anhand von dc.env.example an.", file=sys.stderr)
|
||
return 2
|
||
|
||
timeout = int(os.environ.get("HTTP_TIMEOUT", "30"))
|
||
verify_ssl = env_bool("VERIFY_SSL", True)
|
||
|
||
jellyfin_client = HttpClient(
|
||
jellyfin_url,
|
||
headers={"X-Emby-Token": jellyfin_api_key},
|
||
timeout=timeout,
|
||
verify_ssl=verify_ssl,
|
||
)
|
||
seerr_client = HttpClient(
|
||
seerr_url,
|
||
headers={"X-Api-Key": seerr_api_key},
|
||
timeout=timeout,
|
||
verify_ssl=verify_ssl,
|
||
)
|
||
jellyfin = Jellyfin(
|
||
jellyfin_client,
|
||
username=os.environ.get("JELLYFIN_USERNAME"),
|
||
user_id=os.environ.get("JELLYFIN_USER_ID"),
|
||
)
|
||
seerr = Seerr(seerr_client, Path(args.cache_file).expanduser())
|
||
|
||
try:
|
||
user_id = jellyfin.resolve_user()
|
||
jellyfin_client.get("/System/Info/Public")
|
||
seerr_client.get("/api/v1/status")
|
||
except Exception as exc:
|
||
print(f"Verbindungstest fehlgeschlagen: {compact_error(exc)}", file=sys.stderr)
|
||
return 3
|
||
|
||
print(f"DC Playlist Sync {VERSION}")
|
||
print(f"Jellyfin-Benutzer-ID: {user_id}")
|
||
print(f"Modus: {'DRY-RUN' if dry_run else 'SCHREIBEND'}")
|
||
|
||
try:
|
||
movies = jellyfin.movies()
|
||
series = jellyfin.series()
|
||
except Exception as exc:
|
||
print(f"Jellyfin-Bibliothek konnte nicht gelesen werden: {compact_error(exc)}", file=sys.stderr)
|
||
return 4
|
||
|
||
print(f"Jellyfin-Inventar: {len(movies)} Filme, {len(series)} Serien")
|
||
|
||
total_requests = total_skips = total_failures = 0
|
||
total_playlist_items = total_missing_blocks = 0
|
||
|
||
for profile in profiles:
|
||
print(f"\n{'=' * 72}\nProfil: {profile.playlist_name}\n{'=' * 72}")
|
||
resolved = inspect_profile(profile, jellyfin, seerr, movies, series, args.search_delay)
|
||
|
||
if args.request_missing or dry_run:
|
||
req, skip, fail = request_missing(resolved, seerr, dry_run=dry_run)
|
||
total_requests += req
|
||
total_skips += skip
|
||
total_failures += fail
|
||
|
||
if args.sync_playlist or dry_run:
|
||
try:
|
||
count, missing = sync_playlist(profile, resolved, jellyfin, dry_run=dry_run)
|
||
total_playlist_items += count
|
||
total_missing_blocks += missing
|
||
except Exception as exc:
|
||
print(f"Playlist-Fehler: {compact_error(exc)}", file=sys.stderr)
|
||
total_failures += 1
|
||
|
||
print("\n=== Zusammenfassung ===")
|
||
print(f"Playlist-Einträge: {total_playlist_items}")
|
||
print(f"Unvollständige Blöcke: {total_missing_blocks}")
|
||
print(f"Requests/DRY-Aktionen: {total_requests}")
|
||
print(f"Übersprungen: {total_skips}")
|
||
print(f"Fehler: {total_failures}")
|
||
|
||
return 1 if total_failures else 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|