diff --git a/CHANGELOG-NIGHTLY.md b/CHANGELOG-NIGHTLY.md new file mode 100644 index 0000000..e07287c --- /dev/null +++ b/CHANGELOG-NIGHTLY.md @@ -0,0 +1,29 @@ +# Changelog (Nightly) + +## 0.1.61-nightly - 2026-02-23 + +- Update-Dialog: feste Auswahl mit `Installieren` / `Abbrechen` (kein vertauschter Yes/No-Dialog mehr). +- Versionen im Update-Dialog nach Kanal gefiltert: + - Main: nur `x.y.z` + - Nightly: nur `x.y.z-nightly` +- Installierte Version wird direkt aus `addon.xml` gelesen. +- Beim Kanalwechsel wird direkt die neueste Version aus dem gewaehlten Kanal installiert. + +## 0.1.59-nightly - 2026-02-23 + +- Enthaelt alle Aenderungen aus `0.1.58`. +- Update-Kanal standardmaessig auf `Nightly`. +- Nightly-Repo-URL als Standard gesetzt. +- Settings-Menue neu sortiert: + - Quellen + - Metadaten + - TMDB Erweitert + - Updates + - Debug Global + - Debug Quellen +- Seitengroesse in Listen auf 20 gesetzt. +- `topstream_genre_max_pages` entfernt. + +## Hinweis + +- Nightly ist fuer Tests und kann sich kurzfristig aendern. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9a12673 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog (Stable) + +## 0.1.61 - 2026-02-23 + +- Menues und Labels weiter vereinheitlicht (ASCII-only, einheitliche Texte pro Plugin). +- Update-Bereich ueberarbeitet: + - Kanalwechsel mit direkter Installation der neuesten Kanal-Version. + - Version-Auswahl mit Changelog-Anzeige und klarer Installieren/Abbrechen-Auswahl. + - Anzeige der installierten Version direkt aus lokaler `addon.xml`. + - Kanal-spezifischer Versionsfilter (Main nur stable, Nightly nur `-nightly`). +- Resolver-/Playback-Flow vereinheitlicht und Hoster-URL-Normalisierung zentralisiert. +- Settings aufgeraeumt (strukturierte Kategorien, reduzierte Alt-Optionen). + +## 0.1.58 - 2026-02-23 + +- Menuebezeichnungen vereinheitlicht (`Haeufig gesehen`, `Neuste Titel`). +- `Neue Titel` und `Neueste Folgen` im Menue zu `Neuste Titel` zusammengelegt. +- Hoster-Header-Anpassung zentral nach `resolve_stream_link` eingebaut. +- Hinweis bei Cloudflare-Block durch ResolveURL statt stiller Fehlversuche. +- Update-Einstellungen erweitert (Kanal, manueller Check, optionaler Auto-Check). +- Metadaten-Parsing in AniWorld und Filmpalast nachgezogen (Cover/Plot robuster). +- Topstreamfilm-Suche: fehlender `urlencode`-Import behoben. +- Einige ungenutzte Funktionen entfernt. diff --git a/README.md b/README.md index 55e9d16..7acafb8 100644 --- a/README.md +++ b/README.md @@ -29,20 +29,6 @@ Es durchsucht Provider und startet Streams. - Plugins: `addon/plugins/*_plugin.py` - Settings: `addon/resources/settings.xml` -## TMDB API Key einrichten -- TMDB Account anlegen und API Key (v3) erstellen: `https://www.themoviedb.org/settings/api` -- In Kodi das ViewIT Addon oeffnen: `Einstellungen -> TMDB` -- `TMDB aktivieren` einschalten -- `TMDB API Key` eintragen -- Optional `TMDB Sprache` setzen (z. B. `de-DE`) -- Optional die Anzeige-Optionen aktivieren/deaktivieren: - - `TMDB Beschreibung anzeigen` - - `TMDB Poster und Vorschaubild anzeigen` - - `TMDB Fanart/Backdrop anzeigen` - - `TMDB Bewertung anzeigen` - - `TMDB Stimmen anzeigen` - - `TMDB Besetzung anzeigen` - ## Tests - Dev Pakete installieren: `./.venv/bin/pip install -r requirements-dev.txt` - Tests starten: `./.venv/bin/pytest` diff --git a/addon/addon.xml b/addon/addon.xml index 2ca7853..c15274a 100644 --- a/addon/addon.xml +++ b/addon/addon.xml @@ -1,5 +1,5 @@ - + diff --git a/addon/default.py b/addon/default.py index db9bc5c..1123c82 100644 --- a/addon/default.py +++ b/addon/default.py @@ -14,15 +14,19 @@ from datetime import datetime import importlib.util import inspect import json +import io import os import re import sys import threading import time import xml.etree.ElementTree as ET +import zipfile from pathlib import Path from types import ModuleType -from urllib.parse import parse_qs, urlencode +from urllib.parse import parse_qs, urlencode, urlparse +from urllib.error import URLError +from urllib.request import Request, urlopen def _ensure_windows_selector_policy() -> None: @@ -131,6 +135,7 @@ _TMDB_LOCK = threading.RLock() WATCHED_THRESHOLD = 0.9 POPULAR_MENU_LABEL = "Haeufig gesehen" LATEST_MENU_LABEL = "Neuste Titel" +LIST_PAGE_SIZE = 20 atexit.register(close_all_sessions) @@ -305,10 +310,6 @@ def _playstate_key(*, plugin_name: str, title: str, season: str, episode: str) - return f"{plugin_name}\t{title}\t{season}\t{episode}" -def _playstate_path() -> str: - return _get_log_path("playstate.json") - - def _load_playstate() -> dict[str, dict[str, object]]: return {} @@ -973,6 +974,8 @@ UPDATE_CHANNEL_MAIN = 0 UPDATE_CHANNEL_NIGHTLY = 1 UPDATE_CHANNEL_CUSTOM = 2 AUTO_UPDATE_INTERVAL_SEC = 6 * 60 * 60 +UPDATE_HTTP_TIMEOUT_SEC = 8 +UPDATE_ADDON_ID = "plugin.video.viewit" def _selected_update_channel() -> int: @@ -982,6 +985,43 @@ def _selected_update_channel() -> int: return channel +def _channel_label(channel: int) -> str: + if channel == UPDATE_CHANNEL_NIGHTLY: + return "Nightly" + if channel == UPDATE_CHANNEL_CUSTOM: + return "Custom" + return "Main" + + +def _version_sort_key(version: str) -> tuple[int, ...]: + base = str(version or "").split("-", 1)[0] + parts = [] + for chunk in base.split("."): + try: + parts.append(int(chunk)) + except Exception: + parts.append(0) + while len(parts) < 4: + parts.append(0) + return tuple(parts[:4]) + + +def _is_stable_version(version: str) -> bool: + return bool(re.match(r"^\d+\.\d+\.\d+$", str(version or "").strip())) + + +def _is_nightly_version(version: str) -> bool: + return bool(re.match(r"^\d+\.\d+\.\d+-nightly$", str(version or "").strip())) + + +def _filter_versions_for_channel(channel: int, versions: list[str]) -> list[str]: + if channel == UPDATE_CHANNEL_MAIN: + return [v for v in versions if _is_stable_version(v)] + if channel == UPDATE_CHANNEL_NIGHTLY: + return [v for v in versions if _is_nightly_version(v)] + return list(versions) + + def _resolve_update_info_url() -> str: channel = _selected_update_channel() if channel == UPDATE_CHANNEL_NIGHTLY: @@ -996,6 +1036,234 @@ def _resolve_update_info_url() -> str: return info_url +def _read_text_url(url: str, *, timeout: int = UPDATE_HTTP_TIMEOUT_SEC) -> str: + request = Request(url, headers={"User-Agent": "ViewIT/1.0"}) + response = None + try: + response = urlopen(request, timeout=timeout) + data = response.read() + finally: + if response is not None: + try: + response.close() + except Exception: + pass + return data.decode("utf-8", errors="replace") + + +def _extract_repo_addon_version(xml_text: str, addon_id: str = UPDATE_ADDON_ID) -> str: + try: + root = ET.fromstring(xml_text) + except Exception: + return "-" + + if root.tag == "addon": + return str(root.attrib.get("version") or "-") + + for node in root.findall("addon"): + if str(node.attrib.get("id") or "").strip() == addon_id: + version = str(node.attrib.get("version") or "").strip() + return version or "-" + return "-" + + +def _fetch_repo_addon_version(info_url: str) -> str: + url = _normalize_update_info_url(info_url) + try: + xml_text = _read_text_url(url) + except URLError: + return "-" + except Exception: + return "-" + return _extract_repo_addon_version(xml_text) + + +def _read_binary_url(url: str, *, timeout: int = UPDATE_HTTP_TIMEOUT_SEC) -> bytes: + request = Request(url, headers={"User-Agent": "ViewIT/1.0"}) + response = None + try: + response = urlopen(request, timeout=timeout) + return response.read() + finally: + if response is not None: + try: + response.close() + except Exception: + pass + + +def _extract_repo_identity(info_url: str) -> tuple[str, str, str, str] | None: + parsed = urlparse(str(info_url or "").strip()) + parts = [part for part in parsed.path.split("/") if part] + try: + raw_idx = parts.index("raw") + except ValueError: + return None + # expected: /{owner}/{repo}/raw/branch/{branch}/addons.xml + if raw_idx < 2 or (raw_idx + 2) >= len(parts): + return None + if parts[raw_idx + 1] != "branch": + return None + owner = parts[raw_idx - 2] + repo = parts[raw_idx - 1] + branch = parts[raw_idx + 2] + scheme = parsed.scheme or "https" + host = parsed.netloc + if not owner or not repo or not branch or not host: + return None + return scheme, host, owner, repo + "|" + branch + + +def _fetch_repo_versions(info_url: str) -> list[str]: + identity = _extract_repo_identity(info_url) + if identity is None: + one = _fetch_repo_addon_version(info_url) + return [one] if one != "-" else [] + + scheme, host, owner, repo_branch = identity + repo, branch = repo_branch.split("|", 1) + api_url = f"{scheme}://{host}/api/v1/repos/{owner}/{repo}/contents/{UPDATE_ADDON_ID}?ref={branch}" + + try: + payload = _read_text_url(api_url) + data = json.loads(payload) + except Exception: + one = _fetch_repo_addon_version(info_url) + return [one] if one != "-" else [] + + versions: list[str] = [] + if isinstance(data, list): + for entry in data: + if not isinstance(entry, dict): + continue + name = str(entry.get("name") or "") + match = re.match(rf"^{re.escape(UPDATE_ADDON_ID)}-(.+)\.zip$", name) + if not match: + continue + version = match.group(1).strip() + if version: + versions.append(version) + unique = sorted(set(versions), key=_version_sort_key, reverse=True) + return unique + + +def _extract_changelog_section(changelog_text: str, version: str) -> str: + lines = changelog_text.splitlines() + wanted = (version or "").strip() + if not wanted: + return "\n".join(lines[:120]).strip() + + start = -1 + for idx, line in enumerate(lines): + if line.startswith("## ") and wanted in line: + start = idx + break + if start < 0: + return f"Kein Changelog-Abschnitt fuer Version {wanted} gefunden." + + end = len(lines) + for idx in range(start + 1, len(lines)): + if lines[idx].startswith("## "): + end = idx + break + return "\n".join(lines[start:end]).strip() + + +def _fetch_changelog_for_channel(channel: int, version: str) -> str: + if channel == UPDATE_CHANNEL_MAIN: + url = "https://gitea.it-drui.de/viewit/ViewIT/raw/branch/main/CHANGELOG.md" + else: + url = "https://gitea.it-drui.de/viewit/ViewIT/raw/branch/nightly/CHANGELOG-NIGHTLY.md" + try: + text = _read_text_url(url) + except Exception: + return "Changelog konnte nicht geladen werden." + return _extract_changelog_section(text, version) + + +def _install_addon_version_manual(info_url: str, version: str) -> bool: + base = info_url[: -len("/addons.xml")] if info_url.endswith("/addons.xml") else info_url.rstrip("/") + zip_url = f"{base}/{UPDATE_ADDON_ID}/{UPDATE_ADDON_ID}-{version}.zip" + try: + zip_bytes = _read_binary_url(zip_url) + except Exception as exc: + _log(f"Download fehlgeschlagen ({zip_url}): {exc}", xbmc.LOGWARNING) + return False + + if xbmcvfs is None: + return False + + addons_root = xbmcvfs.translatePath("special://home/addons") + addons_root_real = os.path.realpath(addons_root) + try: + with zipfile.ZipFile(io.BytesIO(zip_bytes)) as archive: + for member in archive.infolist(): + name = str(member.filename or "") + if not name or name.endswith("/"): + continue + target = os.path.realpath(os.path.join(addons_root, name)) + if not target.startswith(addons_root_real + os.sep): + continue + os.makedirs(os.path.dirname(target), exist_ok=True) + with archive.open(member, "r") as src, open(target, "wb") as dst: + dst.write(src.read()) + except Exception as exc: + _log(f"Entpacken fehlgeschlagen: {exc}", xbmc.LOGWARNING) + return False + + builtin = getattr(xbmc, "executebuiltin", None) + if callable(builtin): + builtin("UpdateLocalAddons") + return True + + +def _install_addon_version(info_url: str, version: str) -> bool: + base = info_url[: -len("/addons.xml")] if info_url.endswith("/addons.xml") else info_url.rstrip("/") + zip_url = f"{base}/{UPDATE_ADDON_ID}/{UPDATE_ADDON_ID}-{version}.zip" + + # Prefer Kodi's own installer to get proper install flow and dependency handling. + builtin = getattr(xbmc, "executebuiltin", None) + if callable(builtin): + try: + before = _installed_addon_version_from_disk() + builtin(f"InstallAddon({zip_url})") + for _ in range(20): + time.sleep(1) + current = _installed_addon_version_from_disk() + if current == version: + return True + if before == version: + return True + except Exception as exc: + _log(f"InstallAddon fehlgeschlagen, fallback aktiv: {exc}", xbmc.LOGWARNING) + + return _install_addon_version_manual(info_url, version) + + +def _sync_update_channel_status_settings() -> None: + channel = _selected_update_channel() + channel_label = _channel_label(channel) + + selected_info_url = _resolve_update_info_url() + main_info_url = _normalize_update_info_url(_get_setting_string("update_repo_url_main")) + nightly_info_url = _normalize_update_info_url(_get_setting_string("update_repo_url_nightly")) + + available_main = _fetch_repo_addon_version(main_info_url) + available_nightly = _fetch_repo_addon_version(nightly_info_url) + if channel == UPDATE_CHANNEL_MAIN: + available_selected = available_main + elif channel == UPDATE_CHANNEL_NIGHTLY: + available_selected = available_nightly + else: + available_selected = _fetch_repo_addon_version(selected_info_url) + + _set_setting_string("update_active_channel", channel_label) + _set_setting_string("update_active_repo_url", selected_info_url) + _set_setting_string("update_available_main", available_main) + _set_setting_string("update_available_nightly", available_nightly) + _set_setting_string("update_available_selected", available_selected) + + def _repo_addon_xml_path() -> str: if xbmcvfs is None: return "" @@ -1038,15 +1306,34 @@ def _settings_key_for_plugin(name: str) -> str: return f"update_version_{safe}" if safe else "update_version_unknown" +def _installed_addon_version_from_disk() -> str: + if xbmcvfs is None: + return "0.0.0" + try: + addon_xml = xbmcvfs.translatePath(f"special://home/addons/{UPDATE_ADDON_ID}/addon.xml") + except Exception: + return "0.0.0" + if not addon_xml or not os.path.exists(addon_xml): + return "0.0.0" + try: + root = ET.parse(addon_xml).getroot() + version = str(root.attrib.get("version") or "").strip() + return version or "0.0.0" + except Exception: + return "0.0.0" + + def _sync_update_version_settings() -> None: - addon = _get_addon() - addon_version = "0.0.0" - if addon is not None: - try: - addon_version = str(addon.getAddonInfo("version") or "0.0.0") - except Exception: - addon_version = "0.0.0" + addon_version = _installed_addon_version_from_disk() + if addon_version == "0.0.0": + addon = _get_addon() + if addon is not None: + try: + addon_version = str(addon.getAddonInfo("version") or "0.0.0") + except Exception: + addon_version = "0.0.0" _set_setting_string("update_version_addon", addon_version) + _set_setting_string("update_installed_version", addon_version) versions = { "update_version_serienstream": "-", @@ -1063,6 +1350,8 @@ def _sync_update_version_settings() -> None: for key, value in versions.items(): _set_setting_string(key, value) + _sync_update_channel_status_settings() + def _show_root_menu() -> None: handle = _get_handle() @@ -2574,7 +2863,7 @@ def _plugins_with_popular() -> list[tuple[str, BasisPlugin, str]]: def _show_popular(plugin_name: str | None = None, page: int = 1) -> None: handle = _get_handle() - page_size = 10 + page_size = LIST_PAGE_SIZE page = max(1, int(page or 1)) if plugin_name: @@ -2701,7 +2990,7 @@ def _show_popular(plugin_name: str | None = None, page: int = 1) -> None: def _show_new_titles(plugin_name: str, page: int = 1, *, action_name: str = "new_titles") -> None: handle = _get_handle() - page_size = 10 + page_size = LIST_PAGE_SIZE page = max(1, int(page or 1)) plugin_name = (plugin_name or "").strip() @@ -2949,7 +3238,7 @@ def _show_latest_titles(plugin_name: str, page: int = 1) -> None: def _show_genre_series_group(plugin_name: str, genre: str, group_code: str, page: int = 1) -> None: handle = _get_handle() - page_size = 10 + page_size = LIST_PAGE_SIZE page = max(1, int(page or 1)) plugin = _discover_plugins().get(plugin_name) if plugin is None: @@ -3132,19 +3421,77 @@ def _open_settings() -> None: addon.openSettings() +def _apply_update_channel(*, silent: bool = False) -> bool: + if xbmc is None: # pragma: no cover - outside Kodi + return False + info_url = _resolve_update_info_url() + channel = _selected_update_channel() + _sync_update_version_settings() + applied = _update_repository_source(info_url) + installed_version = _get_setting_string("update_installed_version").strip() or "0.0.0" + versions = _filter_versions_for_channel(channel, _fetch_repo_versions(info_url)) + target_version = versions[0] if versions else "-" + + install_result = False + if target_version != "-" and target_version != installed_version: + install_result = _install_addon_version(info_url, target_version) + elif target_version == installed_version: + install_result = True + + builtin = getattr(xbmc, "executebuiltin", None) + if callable(builtin): + builtin("UpdateAddonRepos") + builtin("UpdateLocalAddons") + if not silent: + if not applied: + warning_icon = getattr(xbmcgui, "NOTIFICATION_WARNING", xbmcgui.NOTIFICATION_INFO) + xbmcgui.Dialog().notification( + "Updates", + "Kanal gespeichert, aber repository.viewit nicht gefunden.", + warning_icon, + 5000, + ) + elif target_version == "-": + xbmcgui.Dialog().notification( + "Updates", + "Kanal angewendet, aber keine Version im Kanal gefunden.", + xbmcgui.NOTIFICATION_ERROR, + 5000, + ) + elif not install_result: + xbmcgui.Dialog().notification( + "Updates", + f"Kanal angewendet, Installation von {target_version} fehlgeschlagen.", + xbmcgui.NOTIFICATION_ERROR, + 5000, + ) + elif target_version == installed_version: + xbmcgui.Dialog().notification( + "Updates", + f"Kanal angewendet: {_channel_label(_selected_update_channel())} ({target_version} bereits installiert)", + xbmcgui.NOTIFICATION_INFO, + 4500, + ) + else: + xbmcgui.Dialog().notification( + "Updates", + f"Kanal angewendet: {_channel_label(_selected_update_channel())} -> {target_version} installiert", + xbmcgui.NOTIFICATION_INFO, + 5000, + ) + _sync_update_version_settings() + return applied and install_result + + def _run_update_check(*, silent: bool = False) -> None: """Stoesst Kodi-Repo- und Addon-Updates an.""" if xbmc is None: # pragma: no cover - outside Kodi return try: - info_url = _resolve_update_info_url() - _sync_update_version_settings() - _update_repository_source(info_url) - builtin = getattr(xbmc, "executebuiltin", None) - if callable(builtin): - builtin("UpdateAddonRepos") - builtin("UpdateLocalAddons") - if not silent: + _apply_update_channel(silent=True) + if not silent: + builtin = getattr(xbmc, "executebuiltin", None) + if callable(builtin): builtin("ActivateWindow(addonbrowser,addons://updates/)") if not silent: xbmcgui.Dialog().notification("Updates", "Update-Check gestartet.", xbmcgui.NOTIFICATION_INFO, 4000) @@ -3157,6 +3504,59 @@ def _run_update_check(*, silent: bool = False) -> None: pass +def _show_version_selector() -> None: + if xbmc is None: # pragma: no cover - outside Kodi + return + + info_url = _resolve_update_info_url() + channel = _selected_update_channel() + _sync_update_version_settings() + + versions = _filter_versions_for_channel(channel, _fetch_repo_versions(info_url)) + if not versions: + xbmcgui.Dialog().notification("Updates", "Keine Versionen im Repo gefunden.", xbmcgui.NOTIFICATION_ERROR, 4000) + return + + installed = _get_setting_string("update_installed_version").strip() or "-" + options = [] + for version in versions: + label = version + if version == installed: + label = f"{version} (installiert)" + options.append(label) + + selected = xbmcgui.Dialog().select("Version waehlen", options) + if selected < 0 or selected >= len(versions): + return + + version = versions[selected] + changelog = _fetch_changelog_for_channel(channel, version) + viewer = getattr(xbmcgui.Dialog(), "textviewer", None) + if callable(viewer): + try: + viewer(f"Changelog {version}", changelog) + except Exception: + pass + + confirm_choice = xbmcgui.Dialog().select( + "Version installieren", + [ + f"Installieren: {version}", + "Abbrechen", + ], + ) + if confirm_choice != 0: + return + + xbmcgui.Dialog().notification("Updates", f"Installation gestartet: {version}", xbmcgui.NOTIFICATION_INFO, 2500) + ok = _install_addon_version(info_url, version) + if ok: + _sync_update_version_settings() + xbmcgui.Dialog().notification("Updates", f"Version {version} installiert.", xbmcgui.NOTIFICATION_INFO, 4000) + else: + xbmcgui.Dialog().notification("Updates", f"Installation von {version} fehlgeschlagen.", xbmcgui.NOTIFICATION_ERROR, 4500) + + def _maybe_run_auto_update_check(action: str | None) -> None: action = (action or "").strip() # Auto-Check nur beim Root-Menue, nicht in jedem Untermenue. @@ -3182,23 +3582,28 @@ def _extract_first_int(value: str) -> int | None: return None -def _duration_label(duration_seconds: int) -> str: - try: - duration_seconds = int(duration_seconds or 0) - except Exception: - duration_seconds = 0 - if duration_seconds <= 0: - return "" - total_minutes = max(0, duration_seconds // 60) - hours = max(0, total_minutes // 60) - minutes = max(0, total_minutes % 60) - return f"{hours:02d}:{minutes:02d} Laufzeit" - - def _label_with_duration(label: str, info_labels: dict[str, str] | None) -> str: return label +def _resolveurl_last_error() -> str: + try: + from resolveurl_backend import get_last_error # type: ignore + except Exception: + return "" + try: + return str(get_last_error() or "") + except Exception: + return "" + + +def _is_cloudflare_challenge_error(message: str) -> bool: + text = str(message or "").casefold() + if not text: + return False + return "cloudflare" in text or "challenge" in text or "attention required" in text + + def _play_final_link( link: str, *, @@ -3242,10 +3647,6 @@ def _play_final_link( player.play(item=link, listitem=list_item) -def _track_playback_and_update_state(key: str) -> None: - return - - def _track_playback_and_update_state_async(key: str) -> None: # Eigenes Resume/Watched ist deaktiviert; Kodi verwaltet das selbst. return @@ -3334,8 +3735,30 @@ def _play_episode( xbmcgui.Dialog().notification("Wiedergabe", "Kein Stream gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) return _log(f"Stream-Link: {link}", xbmc.LOGDEBUG) - final_link = plugin.resolve_stream_link(link) or link + resolved_link = plugin.resolve_stream_link(link) + if not resolved_link: + err = _resolveurl_last_error() + if _is_cloudflare_challenge_error(err): + _log(f"ResolveURL Cloudflare-Challenge: {err}", xbmc.LOGWARNING) + xbmcgui.Dialog().notification( + "Wiedergabe", + "Hoster durch Cloudflare geschuetzt. Bitte spaeter erneut probieren.", + xbmcgui.NOTIFICATION_INFO, + 4500, + ) + return + final_link = resolved_link or link final_link = normalize_resolved_stream_url(final_link, source_url=link) + err = _resolveurl_last_error() + if _is_cloudflare_challenge_error(err) and final_link.strip() == link.strip(): + _log(f"ResolveURL Cloudflare-Challenge (unresolved): {err}", xbmc.LOGWARNING) + xbmcgui.Dialog().notification( + "Wiedergabe", + "Hoster durch Cloudflare geschuetzt. Bitte spaeter erneut probieren.", + xbmcgui.NOTIFICATION_INFO, + 4500, + ) + return finally: if restore_hosters is not None and callable(preferred_setter): preferred_setter(restore_hosters) @@ -3422,8 +3845,30 @@ def _play_episode_url( xbmcgui.Dialog().notification("Wiedergabe", "Kein Stream gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) return _log(f"Stream-Link: {link}", xbmc.LOGDEBUG) - final_link = plugin.resolve_stream_link(link) or link + resolved_link = plugin.resolve_stream_link(link) + if not resolved_link: + err = _resolveurl_last_error() + if _is_cloudflare_challenge_error(err): + _log(f"ResolveURL Cloudflare-Challenge: {err}", xbmc.LOGWARNING) + xbmcgui.Dialog().notification( + "Wiedergabe", + "Hoster durch Cloudflare geschuetzt. Bitte spaeter erneut probieren.", + xbmcgui.NOTIFICATION_INFO, + 4500, + ) + return + final_link = resolved_link or link final_link = normalize_resolved_stream_url(final_link, source_url=link) + err = _resolveurl_last_error() + if _is_cloudflare_challenge_error(err) and final_link.strip() == link.strip(): + _log(f"ResolveURL Cloudflare-Challenge (unresolved): {err}", xbmc.LOGWARNING) + xbmcgui.Dialog().notification( + "Wiedergabe", + "Hoster durch Cloudflare geschuetzt. Bitte spaeter erneut probieren.", + xbmcgui.NOTIFICATION_INFO, + 4500, + ) + return finally: if restore_hosters is not None and callable(preferred_setter): preferred_setter(restore_hosters) @@ -3537,6 +3982,10 @@ def run() -> None: _open_settings() elif action == "check_updates": _run_update_check() + elif action == "apply_update_channel": + _apply_update_channel() + elif action == "select_update_version": + _show_version_selector() elif action == "seasons": _show_seasons(params.get("plugin", ""), params.get("title", ""), params.get("series_url", "")) elif action == "episodes": diff --git a/addon/plugins/einschalten_plugin.py b/addon/plugins/einschalten_plugin.py index b6aea00..d13e122 100644 --- a/addon/plugins/einschalten_plugin.py +++ b/addon/plugins/einschalten_plugin.py @@ -603,15 +603,6 @@ class EinschaltenPlugin(BasisPlugin): url = urljoin(base + "/", path.lstrip("/")) return f"{url}?{urlencode({'query': query})}" - def _api_movies_url(self, *, with_genres: int, page: int = 1) -> str: - base = self._get_base_url() - if not base: - return "" - params: Dict[str, str] = {"withGenres": str(int(with_genres))} - if page and int(page) > 1: - params["page"] = str(int(page)) - return urljoin(base + "/", "api/movies") + f"?{urlencode(params)}" - def _genre_page_url(self, *, genre_id: int, page: int = 1) -> str: """Genre title pages are rendered server-side and embed the movie list in ng-state. @@ -771,23 +762,6 @@ class EinschaltenPlugin(BasisPlugin): except Exception: return [] - def _fetch_new_titles_movies(self) -> List[MovieItem]: - # "Neue Filme" lives at `/movies/new` and embeds the list in ng-state (`u: "/api/movies"`). - url = self._new_titles_url() - if not url: - return [] - try: - _, body = self._http_get_text(url, timeout=20) - payload = _extract_ng_state_payload(body) - movies = _parse_ng_state_movies(payload) - _log_debug_line(f"parse_ng_state_movies:count={len(movies)}") - if movies: - _log_titles(movies, context="new_titles") - return movies - return [] - except Exception: - return [] - def _fetch_new_titles_movies_page(self, page: int) -> List[MovieItem]: page = max(1, int(page or 1)) url = self._new_titles_url() diff --git a/addon/plugins/topstreamfilm_plugin.py b/addon/plugins/topstreamfilm_plugin.py index ab71fd6..099ff87 100644 --- a/addon/plugins/topstreamfilm_plugin.py +++ b/addon/plugins/topstreamfilm_plugin.py @@ -20,7 +20,7 @@ import os import re import json from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional -from urllib.parse import urljoin +from urllib.parse import urlencode, urljoin try: # pragma: no cover - optional dependency import requests @@ -66,12 +66,9 @@ SETTING_LOG_URLS = "log_urls_topstreamfilm" SETTING_DUMP_HTML = "dump_html_topstreamfilm" SETTING_SHOW_URL_INFO = "show_url_info_topstreamfilm" SETTING_LOG_ERRORS = "log_errors_topstreamfilm" -SETTING_GENRE_MAX_PAGES = "topstream_genre_max_pages" DEFAULT_TIMEOUT = 20 DEFAULT_PREFERRED_HOSTERS = ["supervideo", "dropload", "voe"] MEINECLOUD_HOST = "meinecloud.click" -DEFAULT_GENRE_MAX_PAGES = 20 -HARD_MAX_GENRE_PAGES = 200 HEADERS = { "User-Agent": "Mozilla/5.0 (Kodi; ViewIt) AppleWebKit/537.36 (KHTML, like Gecko)", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", @@ -347,22 +344,6 @@ class TopstreamfilmPlugin(BasisPlugin): return urljoin(base if base.endswith("/") else base + "/", href) return href - def _get_setting_bool(self, setting_id: str, *, default: bool = False) -> bool: - return get_setting_bool(ADDON_ID, setting_id, default=default) - - def _get_setting_int(self, setting_id: str, *, default: int) -> int: - if xbmcaddon is None: - return default - try: - addon = xbmcaddon.Addon(ADDON_ID) - getter = getattr(addon, "getSettingInt", None) - if callable(getter): - return int(getter(setting_id)) - raw = str(addon.getSetting(setting_id) or "").strip() - return int(raw) if raw else default - except Exception: - return default - def _notify_url(self, url: str) -> None: notify_url( ADDON_ID, diff --git a/addon/resolveurl_backend.py b/addon/resolveurl_backend.py index 5b9a17a..244c87c 100644 --- a/addon/resolveurl_backend.py +++ b/addon/resolveurl_backend.py @@ -8,8 +8,16 @@ from __future__ import annotations from typing import Optional +_LAST_RESOLVE_ERROR = "" + + +def get_last_error() -> str: + return str(_LAST_RESOLVE_ERROR or "") + def resolve(url: str) -> Optional[str]: + global _LAST_RESOLVE_ERROR + _LAST_RESOLVE_ERROR = "" if not url: return None try: @@ -23,12 +31,14 @@ def resolve(url: str) -> Optional[str]: hmf = hosted(url) valid = getattr(hmf, "valid_url", None) if callable(valid) and not valid(): + _LAST_RESOLVE_ERROR = "invalid url" return None resolver = getattr(hmf, "resolve", None) if callable(resolver): result = resolver() return str(result) if result else None - except Exception: + except Exception as exc: + _LAST_RESOLVE_ERROR = str(exc or "") pass try: @@ -36,8 +46,8 @@ def resolve(url: str) -> Optional[str]: if callable(resolve_fn): result = resolve_fn(url) return str(result) if result else None - except Exception: + except Exception as exc: + _LAST_RESOLVE_ERROR = str(exc or "") return None return None - diff --git a/addon/resources/settings.xml b/addon/resources/settings.xml index 1092fac..e640c9a 100644 --- a/addon/resources/settings.xml +++ b/addon/resources/settings.xml @@ -1,6 +1,66 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -8,83 +68,32 @@ - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -