dev: bump to 0.1.71-dev – Trakt History direkt abspielen, Metadaten + Plugin-Bugfixes

- Trakt History: Episoden starten direkt (kein Staffel-Dialog mehr)
- Trakt History: Episodentitel, Plot und Artwork bereits in der Übersicht
- TraktItem um episode_title, episode_overview, episode_thumb, show_poster, show_fanart erweitert
- get_history() nutzt jetzt ?extended=full,images
- Slash-Commands /check und /deploy angelegt
- build_install_addon.sh deployt jetzt auch nach ~/.kodi/addons/
- filmpalast_plugin: return-Tuple-Bug gefixt (return "", "", "")
- dokustreams_plugin: Regex-Escaping für clean_name() korrigiert
- aniworld_plugin: raise_for_status() in resolve_redirect() ergänzt
- serienstream_plugin: Toter Code und unnötigen Regex-Backslash entfernt
This commit is contained in:
2026-03-01 22:56:51 +01:00
parent 95e14583e0
commit ff30548811
7 changed files with 298 additions and 19 deletions

View File

@@ -54,12 +54,26 @@ class TraktMediaIds:
class TraktItem:
title: str
year: int
media_type: str # "movie" oder "show"
media_type: str # "movie", "show" oder "episode"
ids: TraktMediaIds = field(default_factory=TraktMediaIds)
season: int = 0
episode: int = 0
watched_at: str = ""
poster: str = ""
episode_title: str = "" # Episodentitel (extended=full)
episode_overview: str = "" # Episoden-Inhaltsangabe (extended=full)
episode_thumb: str = "" # Screenshot-URL (extended=images)
show_poster: str = "" # Serien-Poster-URL (extended=images)
show_fanart: str = "" # Serien-Fanart-URL (extended=images)
@dataclass(frozen=True)
class TraktEpisodeMeta:
"""Metadaten einer einzelnen Episode (aus extended=full,images)."""
title: str
overview: str
runtime_minutes: int
thumb: str # Screenshot-URL (https://)
@dataclass(frozen=True)
@@ -71,9 +85,27 @@ class TraktCalendarItem:
season: int
episode: int
episode_title: str
episode_overview: str # Episoden-Inhaltsangabe (extended=full)
episode_thumb: str # Screenshot-URL (https://)
show_poster: str # Poster-URL (https://)
show_fanart: str # Fanart-URL (https://)
first_aired: str # ISO-8601, z.B. "2026-03-02T02:00:00.000Z"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _trakt_image_url(raw: str) -> str:
"""Stellt https:// vor relative Trakt-Bild-URLs."""
if not raw:
return ""
raw = raw.strip()
if raw.startswith("http"):
return raw
return f"https://{raw}"
# ---------------------------------------------------------------------------
# Client
# ---------------------------------------------------------------------------
@@ -332,7 +364,7 @@ class TraktClient:
path = "/users/me/history"
if media_type in ("movies", "shows", "episodes"):
path = f"{path}/{media_type}"
path = f"{path}?page={page}&limit={limit}"
path = f"{path}?page={page}&limit={limit}&extended=full,images"
status, payload = self._get(path, token=token)
if status != 200 or not isinstance(payload, list):
return []
@@ -351,7 +383,7 @@ class TraktClient:
if not start_date:
from datetime import date
start_date = date.today().strftime("%Y-%m-%d")
path = f"/calendars/my/shows/{start_date}/{days}"
path = f"/calendars/my/shows/{start_date}/{days}?extended=full,images"
status, payload = self._get(path, token=token)
if status != 200 or not isinstance(payload, list):
return []
@@ -362,6 +394,13 @@ class TraktClient:
show = entry.get("show") or {}
ep = entry.get("episode") or {}
show_ids = self._parse_ids(show.get("ids") or {})
ep_images = ep.get("images") or {}
show_images = show.get("images") or {}
def _first(img_dict: dict, key: str) -> str:
imgs = img_dict.get(key) or []
return _trakt_image_url(imgs[0]) if imgs else ""
items.append(TraktCalendarItem(
show_title=str(show.get("title", "") or ""),
show_year=int(show.get("year", 0) or 0),
@@ -369,10 +408,75 @@ class TraktClient:
season=int(ep.get("season", 0) or 0),
episode=int(ep.get("number", 0) or 0),
episode_title=str(ep.get("title", "") or ""),
episode_overview=str(ep.get("overview", "") or ""),
episode_thumb=_first(ep_images, "screenshot"),
show_poster=_first(show_images, "poster"),
show_fanart=_first(show_images, "fanart"),
first_aired=str(entry.get("first_aired", "") or ""),
))
return items
def search_show(self, query: str) -> str:
"""GET /search/show?query=... gibt slug des ersten Treffers zurück, sonst ''."""
from urllib.parse import urlencode
path = f"/search/show?{urlencode({'query': query, 'limit': 1})}"
status, payload = self._get(path)
if status != 200 or not isinstance(payload, list) or not payload:
return ""
show = (payload[0] or {}).get("show") or {}
ids = show.get("ids") or {}
return str(ids.get("slug") or ids.get("trakt") or "")
def lookup_tv_season(
self,
show_id_or_slug: "str | int",
season_number: int,
*,
token: str = "",
) -> "dict[int, TraktEpisodeMeta] | None":
"""GET /shows/{id}/seasons/{n}/episodes?extended=full,images
Gibt episode_number -> TraktEpisodeMeta zurück, oder None bei Fehler.
"""
path = f"/shows/{show_id_or_slug}/seasons/{season_number}/episodes?extended=full,images"
status, payload = self._get(path, token=token)
if status != 200 or not isinstance(payload, list):
return None
result: "dict[int, TraktEpisodeMeta]" = {}
for entry in payload:
try:
ep_no = int(entry.get("number") or 0)
except Exception:
continue
if not ep_no:
continue
images = entry.get("images") or {}
screenshots = images.get("screenshot") or []
thumb = _trakt_image_url(screenshots[0]) if screenshots else ""
result[ep_no] = TraktEpisodeMeta(
title=str(entry.get("title") or "").strip(),
overview=str(entry.get("overview") or "").strip(),
runtime_minutes=int(entry.get("runtime") or 0),
thumb=thumb,
)
return result or None
def get_episode_translation(
self,
show_id_or_slug: "str | int",
season: int,
episode: int,
language: str = "de",
) -> "tuple[str, str]":
"""GET /shows/{id}/seasons/{s}/episodes/{e}/translations/{lang}
Gibt (title, overview) in der Zielsprache zurück, oder ('', '') bei Fehler.
"""
path = f"/shows/{show_id_or_slug}/seasons/{season}/episodes/{episode}/translations/{language}"
status, payload = self._get(path)
if status != 200 or not isinstance(payload, list) or not payload:
return "", ""
first = payload[0] if payload else {}
return str(first.get("title") or ""), str(first.get("overview") or "")
# -------------------------------------------------------------------
# Parser
# -------------------------------------------------------------------
@@ -417,6 +521,13 @@ class TraktClient:
show = entry.get("show") or {}
ep = entry.get("episode") or {}
ids = self._parse_ids((show.get("ids") or {}))
ep_images = ep.get("images") or {}
show_images = show.get("images") or {}
def _first_img(img_dict: dict, key: str) -> str:
imgs = img_dict.get(key) or []
return _trakt_image_url(imgs[0]) if imgs else ""
result.append(TraktItem(
title=str(show.get("title", "") or ""),
year=int(show.get("year", 0) or 0),
@@ -425,6 +536,11 @@ class TraktClient:
season=int(ep.get("season", 0) or 0),
episode=int(ep.get("number", 0) or 0),
watched_at=watched_at,
episode_title=str(ep.get("title", "") or ""),
episode_overview=str(ep.get("overview", "") or ""),
episode_thumb=_first_img(ep_images, "screenshot"),
show_poster=_first_img(show_images, "poster"),
show_fanart=_first_img(show_images, "fanart"),
))
else:
media = entry.get("movie") or entry.get("show") or {}

View File

@@ -131,6 +131,7 @@ _MEDIA_TYPE_CACHE: dict[str, str] = {}
_TMDB_SEASON_CACHE: dict[tuple[int, int, str, str], dict[int, tuple[dict[str, str], dict[str, str]]]] = {}
_TMDB_SEASON_SUMMARY_CACHE: dict[tuple[int, int, str, str], tuple[dict[str, str], dict[str, str]]] = {}
_TMDB_EPISODE_CAST_CACHE: dict[tuple[int, int, int, str], list[TmdbCastMember]] = {}
_TRAKT_SEASON_META_CACHE: dict = {}
_TMDB_LOG_PATH: str | None = None
_GENRE_TITLES_CACHE: dict[tuple[str, str], list[str]] = {}
_ADDON_INSTANCE = None
@@ -987,7 +988,9 @@ def _tmdb_episode_labels_and_art(*, title: str, season_label: str, episode_label
_tmdb_labels_and_art(title)
tmdb_id = _tmdb_cache_get(_TMDB_ID_CACHE, title_key)
if not tmdb_id:
return {"title": episode_label}, {}
return _trakt_episode_labels_and_art(
title=title, season_label=season_label, episode_label=episode_label
)
season_number = _extract_first_int(season_label)
episode_number = _extract_first_int(episode_label)
@@ -1003,7 +1006,9 @@ def _tmdb_episode_labels_and_art(*, title: str, season_label: str, episode_label
if cached_season is None:
api_key = _get_setting_string("tmdb_api_key").strip()
if not api_key or api_key == "None":
return {"title": episode_label}, {}
return _trakt_episode_labels_and_art(
title=title, season_label=season_label, episode_label=episode_label
)
log_requests = _get_setting_bool("tmdb_log_requests", default=False)
log_responses = _get_setting_bool("tmdb_log_responses", default=False)
log_fn = _tmdb_file_log if (log_requests or log_responses) else None
@@ -1038,6 +1043,66 @@ def _tmdb_episode_labels_and_art(*, title: str, season_label: str, episode_label
return cached_season.get(episode_number, ({"title": episode_label}, {}))
def _trakt_episode_labels_and_art(
*, title: str, season_label: str, episode_label: str
) -> tuple[dict[str, str], dict[str, str]]:
"""Trakt-Fallback für Episoden-Metadaten wenn TMDB nicht verfügbar.
Lädt Staffel-Episodendaten per Batch (extended=full,images) und optionale
deutsche Übersetzung per Episode (Translations-Endpunkt).
"""
client = _trakt_get_client()
if not client:
return {"title": episode_label}, {}
season_number = _extract_first_int(season_label)
episode_number = _extract_first_int(episode_label)
if season_number is None or episode_number is None:
return {"title": episode_label}, {}
cache_key = (title.strip().casefold(), season_number)
cached = _tmdb_cache_get(_TRAKT_SEASON_META_CACHE, cache_key)
if cached is None:
slug = client.search_show(title)
if not slug:
_tmdb_cache_set(_TRAKT_SEASON_META_CACHE, cache_key, {})
return {"title": episode_label}, {}
meta = client.lookup_tv_season(slug, season_number)
_tmdb_cache_set(_TRAKT_SEASON_META_CACHE, cache_key, {"slug": slug, "episodes": meta or {}})
cached = _tmdb_cache_get(_TRAKT_SEASON_META_CACHE, cache_key)
slug = (cached or {}).get("slug", "")
episodes: dict = (cached or {}).get("episodes", {})
ep = episodes.get(episode_number)
if not ep:
return {"title": episode_label}, {}
ep_title = ep.title or episode_label
ep_overview = ep.overview
language = _get_setting_string("tmdb_language").strip() or "de-DE"
lang_code = language[:2]
if slug and lang_code and lang_code != "en":
trans_key = (cache_key, episode_number, lang_code)
trans_cached = _tmdb_cache_get(_TRAKT_SEASON_META_CACHE, trans_key)
if trans_cached is None:
t_title, t_overview = client.get_episode_translation(slug, season_number, episode_number, lang_code)
trans_cached = {"title": t_title, "overview": t_overview}
_tmdb_cache_set(_TRAKT_SEASON_META_CACHE, trans_key, trans_cached)
if trans_cached.get("title"):
ep_title = trans_cached["title"]
if trans_cached.get("overview"):
ep_overview = trans_cached["overview"]
info: dict[str, str] = {"title": ep_title}
if ep_overview:
info["plot"] = ep_overview
if ep.runtime_minutes:
info["duration"] = str(ep.runtime_minutes * 60)
art: dict[str, str] = {}
if ep.thumb:
art["thumb"] = ep.thumb
return info, art
def _tmdb_episode_cast(*, title: str, season_label: str, episode_label: str) -> list[TmdbCastMember]:
if not _tmdb_enabled():
return []
@@ -4526,12 +4591,29 @@ def _show_trakt_watchlist(media_type: str = "") -> None:
xbmcplugin.endOfDirectory(handle)
return
_set_content(handle, "tvshows")
items = client.get_watchlist(token, media_type=media_type)
for item in items:
label = f"{item.title}"
label = f"{item.title} ({item.year})" if item.year else item.title
tmdb_info, art, _ = _tmdb_labels_and_art(item.title)
info_labels: dict[str, object] = dict(tmdb_info)
info_labels["title"] = label
info_labels["tvshowtitle"] = item.title
if item.year:
label = f"{item.title} ({item.year})"
_add_directory_item(handle, label, "search", {"query": item.title}, is_folder=True)
info_labels["year"] = item.year
info_labels["mediatype"] = "tvshow"
match = _trakt_find_in_plugins(item.title)
if match:
plugin_name, matched_title = match
action = "seasons"
params: dict[str, str] = {"plugin": plugin_name, "title": matched_title}
else:
action = "search"
params = {"query": item.title}
_add_directory_item(handle, label, action, params, is_folder=True, info_labels=info_labels, art=art)
if not items:
xbmcgui.Dialog().notification("Trakt", "Watchlist ist leer.", xbmcgui.NOTIFICATION_INFO, 3000)
xbmcplugin.endOfDirectory(handle)
@@ -4546,14 +4628,66 @@ def _show_trakt_history(page: int = 1) -> None:
xbmcplugin.endOfDirectory(handle)
return
xbmcplugin.setPluginCategory(handle, "Trakt: Zuletzt gesehen")
_set_content(handle, "episodes")
items = client.get_history(token, page=page, limit=LIST_PAGE_SIZE)
for item in items:
label = item.title
if item.media_type == "episode" and item.season and item.episode:
label = f"{item.title} - S{item.season:02d}E{item.episode:02d}"
is_episode = item.media_type == "episode" and item.season and item.episode
# Label mit Episodentitel wenn vorhanden
if is_episode:
ep_title = item.episode_title or f"Episode {item.episode}"
label = f"{item.title} S{item.season:02d}E{item.episode:02d}: {ep_title}"
elif item.year:
label = f"{item.title} ({item.year})"
_add_directory_item(handle, label, "search", {"query": item.title}, is_folder=True)
else:
label = item.title
# Artwork: Trakt-Bilder als Basis, TMDB ergänzt fehlende Keys
art: dict[str, str] = {}
if item.episode_thumb:
art["thumb"] = item.episode_thumb
if item.show_fanart:
art["fanart"] = item.show_fanart
if item.show_poster:
art["poster"] = item.show_poster
_, tmdb_art, _ = _tmdb_labels_and_art(item.title)
for _k, _v in tmdb_art.items():
art.setdefault(_k, _v)
# Info-Labels
info_labels: dict[str, object] = {}
info_labels["title"] = item.episode_title if is_episode and item.episode_title else label
info_labels["tvshowtitle"] = item.title
if item.year:
info_labels["year"] = item.year
if is_episode:
info_labels["season"] = item.season
info_labels["episode"] = item.episode
if item.episode_overview:
info_labels["plot"] = item.episode_overview
info_labels["mediatype"] = "episode" if is_episode else "tvshow"
# Navigation: Episoden direkt abspielen, Serien zur Staffelauswahl
match = _trakt_find_in_plugins(item.title)
if match:
plugin_name, matched_title = match
if is_episode:
action = "play_episode"
params: dict[str, str] = {
"plugin": plugin_name,
"title": matched_title,
"season": f"Staffel {item.season}",
"episode": f"Episode {item.episode}",
}
_add_directory_item(handle, label, action, params, is_folder=False, info_labels=info_labels, art=art)
else:
action = "seasons"
params = {"plugin": plugin_name, "title": matched_title}
_add_directory_item(handle, label, action, params, is_folder=True, info_labels=info_labels, art=art)
else:
_add_directory_item(handle, label, "search", {"query": item.title}, is_folder=True, info_labels=info_labels, art=art)
if len(items) >= LIST_PAGE_SIZE:
_add_directory_item(handle, "Naechste Seite >>", "trakt_history", {"page": str(page + 1)}, is_folder=True)
@@ -4613,8 +4747,20 @@ def _show_trakt_upcoming() -> None:
}
if item.show_year:
info_labels["year"] = item.show_year
if item.episode_overview:
info_labels["plot"] = item.episode_overview
_, art, _ = _tmdb_labels_and_art(item.show_title)
# Artwork: Trakt-Bilder als Basis, TMDB ergänzt fehlende Keys
art: dict[str, str] = {}
if item.episode_thumb:
art["thumb"] = item.episode_thumb
if item.show_fanart:
art["fanart"] = item.show_fanart
if item.show_poster:
art["poster"] = item.show_poster
_, tmdb_art, _ = _tmdb_labels_and_art(item.show_title)
for _k, _v in tmdb_art.items():
art.setdefault(_k, _v)
match = _trakt_find_in_plugins(item.show_title)
if match:
@@ -4736,7 +4882,11 @@ def _show_trakt_continue_watching() -> None:
@_router.route("search")
def _route_search(params: dict[str, str]) -> None:
_show_search()
query = params.get("query", "").strip()
if query:
_show_search_results(query)
else:
_show_search()
@_router.route("plugin_menu")

View File

@@ -593,6 +593,7 @@ def resolve_redirect(target_url: str) -> Optional[str]:
response = None
try:
response = session.get(normalized_url, headers=HEADERS, timeout=DEFAULT_TIMEOUT, allow_redirects=True)
response.raise_for_status()
if response.url:
_log_url(response.url, kind="RESOLVED")
return response.url if response.url else None

View File

@@ -304,7 +304,7 @@ class DokuStreamsPlugin(BasisPlugin):
def clean_name(value: str) -> str:
value = (value or "").strip()
return re.sub(r"\\s*\\(\\d+\\)\\s*$", "", value).strip()
return re.sub(r"\s*\(\d+\)\s*$", "", value).strip()
def walk(ul, parents: List[str]) -> None:
for li in ul.find_all("li", recursive=False):

View File

@@ -728,7 +728,7 @@ class FilmpalastPlugin(BasisPlugin):
def _extract_detail_metadata(self, soup: BeautifulSoupT) -> tuple[str, str, str]:
if not soup:
return "", ""
return "", "", ""
root = soup.select_one("div#content[role='main']") or soup
detail = root.select_one("article.detail") or root
plot = ""

View File

@@ -865,7 +865,7 @@ def _extract_episodes(soup: BeautifulSoupT) -> list[EpisodeInfo]:
onclick = (row.get("onclick") or "").strip()
url = ""
if onclick:
match = re.search(r"location=['\\\"]([^'\\\"]+)['\\\"]", onclick)
match = re.search(r"location=['\"]([^'\"]+)['\"]", onclick)
if match:
url = _absolute_url(match.group(1))
if not url:
@@ -923,8 +923,6 @@ def _extract_episodes(soup: BeautifulSoupT) -> list[EpisodeInfo]:
hosters=hosters,
)
)
if episodes:
return episodes
return episodes