diff --git a/CHANGELOG-DEV.md b/CHANGELOG-DEV.md index 3d5fe69..53a3249 100644 --- a/CHANGELOG-DEV.md +++ b/CHANGELOG-DEV.md @@ -1,3 +1,7 @@ +## 0.1.81.0-dev - 2026-03-14 + +- dev: YouTube Fixes, Trakt Credentials fest, Upcoming Ansicht, Watchlist Kontextmenue + ## 0.1.80.5-dev - 2026-03-13 - dev: YouTube: yt-dlp ZIP-Installation von GitHub, kein yesno-Dialog diff --git a/addon/addon.xml b/addon/addon.xml index 0bbc6f8..697e436 100644 --- a/addon/addon.xml +++ b/addon/addon.xml @@ -1,5 +1,5 @@ - + diff --git a/addon/default.py b/addon/default.py index c616ace..870ea84 100644 --- a/addon/default.py +++ b/addon/default.py @@ -3965,6 +3965,190 @@ def _resolve_stream_with_retry(plugin: BasisPlugin, link: str) -> str | None: return final_link +def _is_inputstream_adaptive_available() -> bool: + """Prueft ob inputstream.adaptive in Kodi installiert ist.""" + try: + import xbmcaddon # type: ignore + xbmcaddon.Addon("inputstream.adaptive") + return True + except Exception: + return False + + +# --------------------------------------------------------------------------- +# Lokaler MPD-Manifest-Server fuer inputstream.adaptive +# --------------------------------------------------------------------------- +_mpd_server_instance = None +_mpd_server_port = 0 + + +def _ensure_mpd_server() -> int: + """Startet einen lokalen HTTP-Server der MPD-Manifeste serviert. + + Gibt den Port zurueck. Der Server laeuft in einem Daemon-Thread. + """ + global _mpd_server_instance, _mpd_server_port + if _mpd_server_instance is not None: + return _mpd_server_port + + import http.server + import socketserver + import threading + + _pending_manifests: dict[str, str] = {} + + class _ManifestHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self) -> None: + if "/manifest" in self.path: + key = self.path.split("key=")[-1].split("&")[0] if "key=" in self.path else "" + content = _pending_manifests.pop(key, "") + if content: + data = content.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/dash+xml") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + return + self.send_error(404) + + def log_message(self, *_args: object) -> None: + pass # kein Logging + + server = socketserver.TCPServer(("127.0.0.1", 0), _ManifestHandler) + _mpd_server_port = server.server_address[1] + _mpd_server_instance = server + + # pending_manifests als Attribut am Server speichern + server._pending_manifests = _pending_manifests # type: ignore[attr-defined] + + t = threading.Thread(target=server.serve_forever, daemon=True) + t.start() + _log(f"MPD-Server gestartet auf Port {_mpd_server_port}", xbmc.LOGDEBUG) + return _mpd_server_port + + +def _register_mpd_manifest(mpd_xml: str) -> str: + """Registriert ein MPD-Manifest und gibt die lokale URL zurueck.""" + import hashlib + port = _ensure_mpd_server() + key = hashlib.md5(mpd_xml.encode()).hexdigest()[:12] + if _mpd_server_instance is not None: + _mpd_server_instance._pending_manifests[key] = mpd_xml # type: ignore[attr-defined] + return f"http://127.0.0.1:{port}/plugin.video.viewit/manifest?key={key}" + + +def _play_dual_stream( + video_url: str, + audio_url: str, + *, + meta: dict[str, str] | None = None, + display_title: str | None = None, + info_labels: dict[str, str] | None = None, + art: dict[str, str] | None = None, + cast: list[TmdbCastMember] | None = None, + resolve_handle: int | None = None, + trakt_media: dict[str, object] | None = None, +) -> None: + """Spielt getrennte Video+Audio-Streams via inputstream.adaptive. + + Startet einen lokalen HTTP-Server der ein generiertes MPD-Manifest + serviert (gem. inputstream.adaptive Wiki: Integration + Custom Manifest). + Fallback auf Video-only wenn inputstream.adaptive nicht installiert. + """ + if not _is_inputstream_adaptive_available(): + _log("inputstream.adaptive nicht verfuegbar – Video-only Wiedergabe", xbmc.LOGWARNING) + _play_final_link( + video_url, display_title=display_title, info_labels=info_labels, + art=art, cast=cast, resolve_handle=resolve_handle, trakt_media=trakt_media, + ) + return + + from xml.sax.saxutils import escape as xml_escape + + m = meta or {} + vcodec = m.get("vc", "avc1.640028") + acodec = m.get("ac", "mp4a.40.2") + w = m.get("w", "1920") + h = m.get("h", "1080") + fps = m.get("fps", "25") + vbr = m.get("vbr", "5000000") + abr = m.get("abr", "128000") + asr = m.get("asr", "44100") + ach = m.get("ach", "2") + dur = m.get("dur", "0") + + dur_attr = "" + if dur and dur != "0": + dur_attr = f' mediaPresentationDuration="PT{dur}S"' + + mpd_xml = ( + '' + '' + '' + '' + '' + '' + xml_escape(video_url) + '' + '' + '' + '' + '' + '' + '' + xml_escape(audio_url) + '' + '' + '' + '' + '' + ) + + mpd_url = _register_mpd_manifest(mpd_xml) + _log(f"MPD-Manifest URL: {mpd_url}", xbmc.LOGDEBUG) + + list_item = xbmcgui.ListItem(label=display_title or "", path=mpd_url) + list_item.setMimeType("application/dash+xml") + list_item.setContentLookup(False) + list_item.setProperty("inputstream", "inputstream.adaptive") + list_item.setProperty("inputstream.adaptive.manifest_type", "mpd") + + merged_info: dict[str, object] = dict(info_labels or {}) + if display_title: + merged_info["title"] = display_title + _apply_video_info(list_item, merged_info, cast) + if art: + setter = getattr(list_item, "setArt", None) + if callable(setter): + try: + setter(art) + except Exception: + pass + + resolved = False + if resolve_handle is not None: + resolver = getattr(xbmcplugin, "setResolvedUrl", None) + if callable(resolver): + try: + resolver(resolve_handle, True, list_item) + resolved = True + except Exception: + pass + if not resolved: + xbmc.Player().play(item=mpd_url, listitem=list_item) + + if trakt_media and _get_setting_bool("trakt_enabled", default=False): + _trakt_scrobble_start_async(trakt_media) + _trakt_monitor_playback(trakt_media) + + def _play_final_link( link: str, *, @@ -3975,6 +4159,25 @@ def _play_final_link( resolve_handle: int | None = None, trakt_media: dict[str, object] | None = None, ) -> None: + # Getrennte Video+Audio-Streams (yt-dlp): via inputstream.adaptive abspielen + audio_url = None + meta: dict[str, str] = {} + try: + from ytdlp_helper import split_video_audio + link, audio_url, meta = split_video_audio(link) + except Exception: + pass + + if audio_url: + _play_dual_stream( + link, audio_url, + meta=meta, + display_title=display_title, info_labels=info_labels, + art=art, cast=cast, resolve_handle=resolve_handle, + trakt_media=trakt_media, + ) + return + list_item = xbmcgui.ListItem(label=display_title or "", path=link) try: list_item.setProperty("IsPlayable", "true") diff --git a/addon/plugins/dokustreams_plugin.py b/addon/plugins/dokustreams_plugin.py index 3afb0f0..ba58493 100644 --- a/addon/plugins/dokustreams_plugin.py +++ b/addon/plugins/dokustreams_plugin.py @@ -286,7 +286,7 @@ class DokuStreamsPlugin(BasisPlugin): soup = _get_soup(search_url, session=session) except Exception: return [] - return _parse_listing_hits(soup, query=query) + return _parse_listing_hits(soup) def capabilities(self) -> set[str]: return {"genres", "popular_series", "tags", "random"} @@ -455,15 +455,24 @@ class DokuStreamsPlugin(BasisPlugin): art = {"thumb": poster, "poster": poster} return info, art, None + def series_url_for_title(self, title: str) -> Optional[str]: + return self._title_to_url.get((title or "").strip()) + + def remember_series_url(self, title: str, url: str) -> None: + title = (title or "").strip() + url = (url or "").strip() + if title and url: + self._title_to_url[title] = url + def seasons_for(self, title: str) -> List[str]: title = (title or "").strip() - if not title or title not in self._title_to_url: + if not title: return [] return ["Stream"] def episodes_for(self, title: str, season: str) -> List[str]: title = (title or "").strip() - if not title or title not in self._title_to_url: + if not title: return [] return [title] @@ -537,6 +546,14 @@ class DokuStreamsPlugin(BasisPlugin): """Folgt Redirects und versucht ResolveURL fuer Hoster-Links.""" if not link: return None + # YouTube-URLs via yt-dlp aufloesen + from ytdlp_helper import extract_youtube_id, resolve_youtube_url + yt_id = extract_youtube_id(link) + if yt_id: + resolved = resolve_youtube_url(yt_id) + if resolved: + return resolved + return None from plugin_helpers import resolve_via_resolveurl resolved = resolve_via_resolveurl(link, fallback_to_link=False) if resolved: diff --git a/addon/plugins/youtube_plugin.py b/addon/plugins/youtube_plugin.py index ef8a696..7da3a77 100644 --- a/addon/plugins/youtube_plugin.py +++ b/addon/plugins/youtube_plugin.py @@ -135,7 +135,7 @@ def _videos_from_search_data(data: dict) -> List[str]: def _search_with_ytdlp(query: str, count: int = 20) -> List[str]: """Sucht YouTube-Videos via yt-dlp ytsearch-Extraktor.""" - if not _ensure_ytdlp_in_path(): + if not ensure_ytdlp_in_path(): return [] try: from yt_dlp import YoutubeDL # type: ignore @@ -174,95 +174,7 @@ def _fetch_search_videos(url: str) -> List[str]: return [] -def _fix_strptime() -> None: - """Kodi-Workaround: datetime.strptime ist manchmal None.""" - import datetime as _dt - import time as _time - if not callable(getattr(_dt.datetime, "strptime", None)): - _dt.datetime.strptime = lambda s, f: _dt.datetime(*(_time.strptime(s, f)[0:6])) - - -def _ensure_ytdlp_in_path() -> bool: - """Fuegt script.module.yt-dlp/lib zum sys.path hinzu falls noetig.""" - _fix_strptime() - try: - import yt_dlp # type: ignore # noqa: F401 - return True - except ImportError: - pass - try: - import sys, os - import xbmcvfs # type: ignore - lib_path = xbmcvfs.translatePath("special://home/addons/script.module.yt-dlp/lib") - if lib_path and os.path.isdir(lib_path) and lib_path not in sys.path: - sys.path.insert(0, lib_path) - import yt_dlp # type: ignore # noqa: F401 - return True - except Exception: - pass - return False - - -def _get_quality_format() -> str: - """Liest YouTube-Qualitaet aus den Addon-Einstellungen.""" - _QUALITY_MAP = { - "0": "best[ext=mp4]/best", - "1": "bestvideo[height<=1080][ext=mp4]+bestaudio[ext=m4a]/best[height<=1080][ext=mp4]/best", - "2": "bestvideo[height<=720][ext=mp4]+bestaudio[ext=m4a]/best[height<=720][ext=mp4]/best", - "3": "bestvideo[height<=480][ext=mp4]+bestaudio[ext=m4a]/best[height<=480][ext=mp4]/best", - "4": "bestvideo[height<=360][ext=mp4]+bestaudio[ext=m4a]/best[height<=360][ext=mp4]/best", - } - try: - import xbmcaddon # type: ignore - val = xbmcaddon.Addon().getSetting("youtube_quality") or "0" - return _QUALITY_MAP.get(val, _QUALITY_MAP["0"]) - except Exception: - return _QUALITY_MAP["0"] - - -def _resolve_with_ytdlp(video_id: str) -> Optional[str]: - """Loest Video-ID via yt-dlp zu direkter Stream-URL auf.""" - if not _ensure_ytdlp_in_path(): - _log("[YouTube] yt-dlp nicht verfuegbar (script.module.yt-dlp fehlt)") - try: - import xbmcgui - xbmcgui.Dialog().notification( - "yt-dlp fehlt", - "Bitte yt-dlp in den ViewIT-Einstellungen installieren.", - xbmcgui.NOTIFICATION_ERROR, - 5000, - ) - except Exception: - pass - return None - try: - from yt_dlp import YoutubeDL # type: ignore - except ImportError: - return None - url = f"https://www.youtube.com/watch?v={video_id}" - fmt = _get_quality_format() - ydl_opts: Dict[str, Any] = { - "format": fmt, - "quiet": True, - "no_warnings": True, - "extract_flat": False, - } - try: - with YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(url, download=False) - if not info: - return None - # Einzelnes Video - direct = info.get("url") - if direct: - return direct - # Formatauswahl - formats = info.get("formats", []) - if formats: - return formats[-1].get("url") - except Exception as exc: - _log(f"[YouTube] yt-dlp Fehler fuer {video_id}: {exc}") - return None +from ytdlp_helper import ensure_ytdlp_in_path, resolve_youtube_url # --------------------------------------------------------------------------- @@ -306,7 +218,7 @@ class YoutubePlugin(BasisPlugin): video_id = _decode_id(episode) or _decode_id(title) if not video_id: return None - return _resolve_with_ytdlp(video_id) + return resolve_youtube_url(video_id) def resolve_stream_link(self, link: str) -> Optional[str]: return link # bereits direkte URL diff --git a/addon/ytdlp_helper.py b/addon/ytdlp_helper.py new file mode 100644 index 0000000..039bc8c --- /dev/null +++ b/addon/ytdlp_helper.py @@ -0,0 +1,185 @@ +"""Gemeinsame yt-dlp Hilfsfunktionen fuer YouTube-Wiedergabe. + +Wird von youtube_plugin und dokustreams_plugin genutzt. +""" + +from __future__ import annotations + +import re +from typing import Any, Dict, Optional + +try: + import xbmc # type: ignore + def _log(msg: str) -> None: + xbmc.log(f"[ViewIt][yt-dlp] {msg}", xbmc.LOGWARNING) +except ImportError: + def _log(msg: str) -> None: + pass + + +_YT_ID_RE = re.compile( + r"(?:youtube(?:-nocookie)?\.com/(?:embed/|v/|watch\?.*?v=)|youtu\.be/)" + r"([A-Za-z0-9_-]{11})" +) + + +def extract_youtube_id(url: str) -> Optional[str]: + """Extrahiert eine YouTube Video-ID aus verschiedenen URL-Formaten.""" + if not url: + return None + m = _YT_ID_RE.search(url) + return m.group(1) if m else None + + +def _fix_strptime() -> None: + """Kodi-Workaround: datetime.strptime Race Condition vermeiden. + + Kodi's eingebetteter Python kann in Multi-Thread-Umgebungen dazu fuehren + dass der lazy _strptime-Import fehlschlaegt. Wir importieren das Modul + direkt, damit es beim yt-dlp Aufruf bereits geladen ist. + """ + try: + import _strptime # noqa: F401 – erzwingt den internen Import + except Exception: + pass + + +def ensure_ytdlp_in_path() -> bool: + """Fuegt script.module.yt-dlp/lib zum sys.path hinzu falls noetig.""" + _fix_strptime() + try: + import yt_dlp # type: ignore # noqa: F401 + return True + except ImportError: + pass + try: + import sys, os + import xbmcvfs # type: ignore + lib_path = xbmcvfs.translatePath("special://home/addons/script.module.yt-dlp/lib") + if lib_path and os.path.isdir(lib_path) and lib_path not in sys.path: + sys.path.insert(0, lib_path) + import yt_dlp # type: ignore # noqa: F401 + return True + except Exception: + pass + return False + + +def get_quality_format() -> str: + """Liest YouTube-Qualitaet aus den Addon-Einstellungen.""" + _QUALITY_MAP = { + "0": "bestvideo[ext=mp4][vcodec^=avc1]+bestaudio[ext=m4a]/bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best", + "1": "bestvideo[height<=1080][ext=mp4][vcodec^=avc1]+bestaudio[ext=m4a]/bestvideo[height<=1080][ext=mp4]+bestaudio[ext=m4a]/best[height<=1080][ext=mp4]/best", + "2": "bestvideo[height<=720][ext=mp4][vcodec^=avc1]+bestaudio[ext=m4a]/bestvideo[height<=720][ext=mp4]+bestaudio[ext=m4a]/best[height<=720][ext=mp4]/best", + "3": "bestvideo[height<=480][ext=mp4][vcodec^=avc1]+bestaudio[ext=m4a]/bestvideo[height<=480][ext=mp4]+bestaudio[ext=m4a]/best[height<=480][ext=mp4]/best", + "4": "bestvideo[height<=360][ext=mp4][vcodec^=avc1]+bestaudio[ext=m4a]/bestvideo[height<=360][ext=mp4]+bestaudio[ext=m4a]/best[height<=360][ext=mp4]/best", + } + try: + import xbmcaddon # type: ignore + val = xbmcaddon.Addon().getSetting("youtube_quality") or "0" + return _QUALITY_MAP.get(val, _QUALITY_MAP["0"]) + except Exception: + return _QUALITY_MAP["0"] + + +_AUDIO_SEP = "||AUDIO||" +_META_SEP = "||META||" + + +def resolve_youtube_url(video_id: str) -> Optional[str]: + """Loest eine YouTube Video-ID via yt-dlp zu einer direkten Stream-URL auf. + + Bei getrennten Video+Audio-Streams wird der Rueckgabestring im Format + ``video_url||AUDIO||audio_url||META||key=val,key=val,...`` kodiert. + Der Aufrufer kann mit ``split_video_audio()`` alle Teile trennen. + """ + if not ensure_ytdlp_in_path(): + _log("yt-dlp nicht verfuegbar (script.module.yt-dlp fehlt)") + try: + import xbmcgui # type: ignore + xbmcgui.Dialog().notification( + "yt-dlp fehlt", + "Bitte yt-dlp in den ViewIT-Einstellungen installieren.", + xbmcgui.NOTIFICATION_ERROR, + 5000, + ) + except Exception: + pass + return None + try: + from yt_dlp import YoutubeDL # type: ignore + except ImportError: + return None + url = f"https://www.youtube.com/watch?v={video_id}" + fmt = get_quality_format() + ydl_opts: Dict[str, Any] = { + "format": fmt, + "quiet": True, + "no_warnings": True, + "extract_flat": False, + } + try: + with YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(url, download=False) + if not info: + return None + duration = int(info.get("duration") or 0) + # Einzelne URL (kombinierter Stream) + direct = info.get("url") + if direct: + return direct + # Getrennte Video+Audio-Streams (hoehere Qualitaet) + rf = info.get("requested_formats") + if rf and len(rf) >= 2: + vf, af = rf[0], rf[1] + video_url = vf.get("url") + audio_url = af.get("url") + if video_url and audio_url: + vcodec = vf.get("vcodec") or "avc1.640028" + acodec = af.get("acodec") or "mp4a.40.2" + w = int(vf.get("width") or 1920) + h = int(vf.get("height") or 1080) + fps = int(vf.get("fps") or 25) + vbr = int((vf.get("tbr") or 5000) * 1000) + abr = int((af.get("tbr") or 128) * 1000) + asr = int(af.get("asr") or 44100) + ach = int(af.get("audio_channels") or 2) + meta = ( + f"vc={vcodec},ac={acodec}," + f"w={w},h={h},fps={fps}," + f"vbr={vbr},abr={abr}," + f"asr={asr},ach={ach},dur={duration}" + ) + _log(f"Getrennte Streams: {h}p {vcodec} + {acodec}") + return f"{video_url}{_AUDIO_SEP}{audio_url}{_META_SEP}{meta}" + if video_url: + return video_url + # Fallback: letztes Format + formats = info.get("formats", []) + if formats: + return formats[-1].get("url") + except Exception as exc: + _log(f"yt-dlp Fehler fuer {video_id}: {exc}") + return None + + +def split_video_audio(url: str) -> tuple: + """Trennt eine URL in (video_url, audio_url, meta_dict). + + Falls kein Audio-Teil vorhanden: (url, None, {}). + meta_dict enthaelt Keys: vc, ac, w, h, fps, vbr, abr, asr, ach, dur + """ + if _AUDIO_SEP not in url: + return url, None, {} + parts = url.split(_AUDIO_SEP, 1) + video_url = parts[0] + rest = parts[1] + meta: Dict[str, str] = {} + audio_url = rest + if _META_SEP in rest: + audio_url, meta_str = rest.split(_META_SEP, 1) + for pair in meta_str.split(","): + if "=" in pair: + k, v = pair.split("=", 1) + meta[k] = v + return video_url, audio_url, meta