From d51505e0043f82b7ca72780d1dc9775a28957c50 Mon Sep 17 00:00:00 2001 From: "itdrui.de" Date: Sat, 14 Mar 2026 12:50:39 +0100 Subject: [PATCH] dev: YouTube Fixes, Trakt Credentials fest, Upcoming Ansicht, Watchlist Kontextmenue --- CHANGELOG-DEV.md | 4 + addon/addon.xml | 2 +- addon/default.py | 126 ++++++++++++++++++++++---------- addon/plugins/youtube_plugin.py | 84 +++++++++++++++++---- addon/resources/settings.xml | 4 +- docs/TRAKT.md | 111 ++++++++++++++++++++++++++++ 6 files changed, 276 insertions(+), 55 deletions(-) create mode 100644 docs/TRAKT.md diff --git a/CHANGELOG-DEV.md b/CHANGELOG-DEV.md index 7b4826c..3d5fe69 100644 --- a/CHANGELOG-DEV.md +++ b/CHANGELOG-DEV.md @@ -1,3 +1,7 @@ +## 0.1.80.5-dev - 2026-03-13 + +- dev: YouTube: yt-dlp ZIP-Installation von GitHub, kein yesno-Dialog + ## 0.1.80.0-dev - 2026-03-13 - dev: YouTube-Plugin: yt-dlp Suche, Bug-Fix Any-Import diff --git a/addon/addon.xml b/addon/addon.xml index e451752..0bbc6f8 100644 --- a/addon/addon.xml +++ b/addon/addon.xml @@ -1,5 +1,5 @@ - + diff --git a/addon/default.py b/addon/default.py index 9faf85c..c616ace 100644 --- a/addon/default.py +++ b/addon/default.py @@ -227,14 +227,14 @@ def _trakt_save_token(token) -> None: addon.setSetting("trakt_token_expires", str(token.expires_at)) +TRAKT_CLIENT_ID = "5f1a46be11faa2ef286d6a5d4fbdcdfe3b19c87d3799c11af8cf25dae5b802e9" +TRAKT_CLIENT_SECRET = "7b694c47c13565197c3549c7467e92999f36fb2d118f7c185736ec960af22405" + + def _trakt_get_client(): - """Erstellt einen TraktClient falls client_id und client_secret konfiguriert sind.""" - client_id = _get_setting_string("trakt_client_id").strip() - client_secret = _get_setting_string("trakt_client_secret").strip() - if not client_id or not client_secret: - return None + """Erstellt einen TraktClient mit den fest hinterlegten Credentials.""" from core.trakt import TraktClient - return TraktClient(client_id, client_secret, log=lambda m: _log(m, xbmc.LOGDEBUG)) + return TraktClient(TRAKT_CLIENT_ID, TRAKT_CLIENT_SECRET, log=lambda m: _log(m, xbmc.LOGDEBUG)) def _trakt_get_valid_token() -> str: @@ -1167,6 +1167,7 @@ def _add_directory_item( info_labels: dict[str, str] | None = None, art: dict[str, str] | None = None, cast: list[TmdbCastMember] | None = None, + context_menu: list[tuple[str, str]] | None = None, ) -> None: """Fuegt einen Eintrag (Folder oder Playable) in die Kodi-Liste ein.""" query: dict[str, str] = {"action": action} @@ -1187,6 +1188,11 @@ def _add_directory_item( setter(art) except Exception: pass + if context_menu: + try: + item.addContextMenuItems(context_menu) + except Exception: + pass xbmcplugin.addDirectoryItem(handle=handle, url=url, listitem=item, isFolder=is_folder) @@ -4015,21 +4021,27 @@ def _trakt_scrobble_start_async(media: dict[str, object]) -> None: from core.trakt import TraktClient except Exception: return - client_id = _get_setting_string("trakt_client_id").strip() - client_secret = _get_setting_string("trakt_client_secret").strip() access_token = _get_setting_string("trakt_access_token").strip() - if not client_id or not client_secret or not access_token: + if not access_token: return - client = TraktClient(client_id, client_secret, log=lambda m: _log(m, xbmc.LOGDEBUG)) + client = TraktClient(TRAKT_CLIENT_ID, TRAKT_CLIENT_SECRET, log=lambda m: _log(m, xbmc.LOGDEBUG)) + media_type = str(media.get("kind", "movie")) + tmdb_id = int(media.get("tmdb_id", 0)) + imdb_id = str(media.get("imdb_id", "")) client.scrobble_start( access_token, - media_type=str(media.get("kind", "movie")), + media_type=media_type, title=str(media.get("title", "")), - tmdb_id=int(media.get("tmdb_id", 0)), - imdb_id=str(media.get("imdb_id", "")), + tmdb_id=tmdb_id, + imdb_id=imdb_id, season=int(media.get("season", 0)), episode=int(media.get("episode", 0)), ) + if _get_setting_bool("trakt_auto_watchlist", default=False) and (tmdb_id or imdb_id): + try: + client.add_to_watchlist(access_token, media_type=media_type, tmdb_id=tmdb_id, imdb_id=imdb_id) + except Exception: + pass threading.Thread(target=_do, daemon=True).start() @@ -4040,12 +4052,10 @@ def _trakt_scrobble_stop_async(media: dict[str, object], progress: float = 100.0 from core.trakt import TraktClient except Exception: return - client_id = _get_setting_string("trakt_client_id").strip() - client_secret = _get_setting_string("trakt_client_secret").strip() access_token = _get_setting_string("trakt_access_token").strip() - if not client_id or not client_secret or not access_token: + if not access_token: return - client = TraktClient(client_id, client_secret, log=lambda m: _log(m, xbmc.LOGDEBUG)) + client = TraktClient(TRAKT_CLIENT_ID, TRAKT_CLIENT_SECRET, log=lambda m: _log(m, xbmc.LOGDEBUG)) client.scrobble_stop( access_token, media_type=str(media.get("kind", "movie")), @@ -4652,13 +4662,11 @@ def _trakt_authorize() -> None: time.sleep(code.interval) from core.trakt import TraktClient # Einzelversuch (kein internes Polling – wir steuern die Schleife selbst) - client_id = _get_setting_string("trakt_client_id").strip() - client_secret = _get_setting_string("trakt_client_secret").strip() - tmp_client = TraktClient(client_id, client_secret, log=lambda m: _log(m, xbmc.LOGDEBUG)) + tmp_client = TraktClient(TRAKT_CLIENT_ID, TRAKT_CLIENT_SECRET, log=lambda m: _log(m, xbmc.LOGDEBUG)) status, payload = tmp_client._post("/oauth/device/token", { "code": code.device_code, - "client_id": client_id, - "client_secret": client_secret, + "client_id": TRAKT_CLIENT_ID, + "client_secret": TRAKT_CLIENT_SECRET, }) if status == 200 and isinstance(payload, dict): from core.trakt import TraktToken @@ -4775,6 +4783,15 @@ def _show_trakt_history(page: int = 1) -> None: info_labels["plot"] = item.episode_overview info_labels["mediatype"] = "episode" if is_episode else "tvshow" + # Kontextmenue: Zur Watchlist hinzufuegen + ctx: list[tuple[str, str]] = [] + if item.ids and (item.ids.tmdb or item.ids.imdb): + wl_type = "movie" if item.media_type == "movie" else "tv" + wl_params = urlencode({"action": "trakt_watchlist_add", "type": wl_type, + "tmdb_id": str(item.ids.tmdb), "imdb_id": item.ids.imdb}) + ctx.append(("Zur Trakt-Watchlist hinzufuegen", + f"RunPlugin({sys.argv[0]}?{wl_params})")) + # Navigation: Episoden direkt abspielen, Serien zur Staffelauswahl match = _trakt_find_in_plugins(item.title) if match: @@ -4787,13 +4804,13 @@ def _show_trakt_history(page: int = 1) -> None: "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) + _add_directory_item(handle, label, action, params, is_folder=False, info_labels=info_labels, art=art, context_menu=ctx or None) 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) + _add_directory_item(handle, label, action, params, is_folder=True, info_labels=info_labels, art=art, context_menu=ctx or None) else: - _add_directory_item(handle, label, "search", {"query": item.title}, is_folder=True, info_labels=info_labels, art=art) + _add_directory_item(handle, label, "search", {"query": item.title}, is_folder=True, info_labels=info_labels, art=art, context_menu=ctx or None) if len(items) >= LIST_PAGE_SIZE: _add_directory_item(handle, "Naechste Seite >>", "trakt_history", {"page": str(page + 1)}, is_folder=True) @@ -4813,7 +4830,7 @@ def _show_trakt_upcoming() -> None: return xbmcplugin.setPluginCategory(handle, "Trakt: Upcoming") - _set_content(handle, "episodes") + _set_content(handle, "tvshows") try: from core.trakt import TraktCalendarItem as _TCI # noqa: F401 @@ -4829,20 +4846,50 @@ def _show_trakt_upcoming() -> None: xbmcplugin.endOfDirectory(handle) return + from datetime import datetime, date as _date + + _WEEKDAYS = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"] + today = _date.today() + + # Datum pro Item berechnen und nach Datum gruppieren + dated_items: list[tuple[_date, object]] = [] for item in items: - # Datum aufbereiten: ISO -> lesbares Datum - airdate = "" + airdate = today if item.first_aired: try: - from datetime import datetime, timezone dt = datetime.fromisoformat(item.first_aired.replace("Z", "+00:00")) - airdate = dt.astimezone(tz=None).strftime("%d.%m.%Y") + airdate = dt.astimezone(tz=None).date() except Exception: - airdate = item.first_aired[:10] + pass + dated_items.append((airdate, item)) + last_date: _date | None = None + for airdate, item in dated_items: + # Datums-Ueberschrift einfuegen + if airdate != last_date: + last_date = airdate + delta = (airdate - today).days + if delta == 0: + heading = "Heute" + elif delta == 1: + heading = "Morgen" + elif 2 <= delta <= 6: + heading = _WEEKDAYS[airdate.weekday()] + else: + heading = f"{_WEEKDAYS[airdate.weekday()]} {airdate.strftime('%d.%m.')}" + sep = xbmcgui.ListItem(label=f"[B]{heading}[/B]") + sep.setProperty("IsPlayable", "false") + try: + _apply_video_info(sep, {"title": heading, "mediatype": "video"}, None) + except Exception: + pass + xbmcplugin.addDirectoryItem(handle=handle, url="", listitem=sep, isFolder=False) + + # Episoden-Label mit Titel + ep_title = item.episode_title or "" label = f"{item.show_title} \u2013 S{item.season:02d}E{item.episode:02d}" - if airdate: - label = f"{label} ({airdate})" + if ep_title: + label = f"{label}: {ep_title}" info_labels: dict[str, object] = { "title": label, @@ -4855,15 +4902,18 @@ def _show_trakt_upcoming() -> None: info_labels["year"] = item.show_year if item.episode_overview: info_labels["plot"] = item.episode_overview + if ep_title: + info_labels["tagline"] = ep_title - # Artwork: Trakt-Bilder als Basis, TMDB ergänzt fehlende Keys + # Artwork: Trakt-Bilder als Basis, TMDB ergaenzt 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["thumb"] = item.show_poster art["poster"] = item.show_poster + if item.episode_thumb: + art["fanart"] = item.episode_thumb + elif item.show_fanart: + art["fanart"] = item.show_fanart _, tmdb_art, _ = _tmdb_labels_and_art(item.show_title) for _k, _v in tmdb_art.items(): art.setdefault(_k, _v) diff --git a/addon/plugins/youtube_plugin.py b/addon/plugins/youtube_plugin.py index 5c9be29..ef8a696 100644 --- a/addon/plugins/youtube_plugin.py +++ b/addon/plugins/youtube_plugin.py @@ -18,7 +18,14 @@ except ImportError: requests = None # type: ignore from plugin_interface import BasisPlugin -from plugin_helpers import log_error + +try: + import xbmc # type: ignore + def _log(msg: str) -> None: + xbmc.log(f"[ViewIt][YouTube] {msg}", xbmc.LOGWARNING) +except ImportError: + def _log(msg: str) -> None: + pass # --------------------------------------------------------------------------- # Konstanten @@ -121,13 +128,15 @@ def _videos_from_search_data(data: dict) -> List[str]: if title and video_id: results.append(_encode(title, video_id)) except Exception as exc: - log_error(f"[YouTube] _videos_from_search_data Fehler: {exc}") + _log(f"[YouTube] _videos_from_search_data Fehler: {exc}") return results 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(): + return [] try: from yt_dlp import YoutubeDL # type: ignore except ImportError: @@ -144,7 +153,7 @@ def _search_with_ytdlp(query: str, count: int = 20) -> List[str]: if e.get("id") and e.get("title") ] except Exception as exc: - log_error(f"[YouTube] yt-dlp Suche Fehler: {exc}") + _log(f"[YouTube] yt-dlp Suche Fehler: {exc}") return [] @@ -161,16 +170,60 @@ def _fetch_search_videos(url: str) -> List[str]: return [] return _videos_from_search_data(data) except Exception as exc: - log_error(f"[YouTube] _fetch_search_videos ({url}): {exc}") + _log(f"[YouTube] _fetch_search_videos ({url}): {exc}") 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.""" - try: - from yt_dlp import YoutubeDL # type: ignore - except ImportError: - log_error("[YouTube] yt-dlp nicht verfuegbar (script.module.yt-dlp fehlt)") + if not _ensure_ytdlp_in_path(): + _log("[YouTube] yt-dlp nicht verfuegbar (script.module.yt-dlp fehlt)") try: import xbmcgui xbmcgui.Dialog().notification( @@ -182,9 +235,14 @@ def _resolve_with_ytdlp(video_id: str) -> Optional[str]: 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": "best[ext=mp4]/best", + "format": fmt, "quiet": True, "no_warnings": True, "extract_flat": False, @@ -203,7 +261,7 @@ def _resolve_with_ytdlp(video_id: str) -> Optional[str]: if formats: return formats[-1].get("url") except Exception as exc: - log_error(f"[YouTube] yt-dlp Fehler fuer {video_id}: {exc}") + _log(f"[YouTube] yt-dlp Fehler fuer {video_id}: {exc}") return None @@ -214,8 +272,7 @@ def _resolve_with_ytdlp(video_id: str) -> Optional[str]: class YoutubePlugin(BasisPlugin): name = "YouTube" - # Pseudo-Staffeln: nur Suche – Browse-Endpunkte erfordern Login - _SEASONS = ["Suche"] + _SEASONS = ["Stream"] def capabilities(self) -> Set[str]: return set() @@ -241,8 +298,7 @@ class YoutubePlugin(BasisPlugin): return list(self._SEASONS) def episodes_for(self, title: str, season: str) -> List[str]: - if season == "Suche": - # Titel ist bereits ein kodierter Eintrag aus der Suche + if season == "Stream": return [title] return [] diff --git a/addon/resources/settings.xml b/addon/resources/settings.xml index f183c5b..c1b76fa 100644 --- a/addon/resources/settings.xml +++ b/addon/resources/settings.xml @@ -78,10 +78,9 @@ - - + @@ -124,6 +123,7 @@ + diff --git a/docs/TRAKT.md b/docs/TRAKT.md new file mode 100644 index 0000000..65556a5 --- /dev/null +++ b/docs/TRAKT.md @@ -0,0 +1,111 @@ +Trakt in ViewIT – Benutzeranleitung + + +Was ist Trakt? + +Trakt (https://trakt.tv) ist ein kostenloser Dienst, der verfolgt welche Serien und Filme du schaust. Damit kannst du: +- Sehen, wo du bei einer Serie aufgehoert hast +- Neue Episoden deiner Serien im Blick behalten +- Deinen kompletten Schauverlauf geraeteuebergreifend synchronisieren + + +Einrichtung + +1) Trakt-Konto erstellen + +Falls du noch kein Konto hast, registriere dich kostenlos auf https://trakt.tv/auth/join + +2) Trakt in ViewIT aktivieren + +- Oeffne ViewIT in Kodi +- Gehe zu Einstellungen (Zahnrad-Symbol oder Kontextmenue) +- Wechsle zur Kategorie "Trakt" +- Setze "Trakt aktivieren" auf An + +3) Trakt autorisieren + +- Klicke auf "Trakt autorisieren" +- ViewIT zeigt dir einen Code und eine URL an +- Oeffne https://trakt.tv/activate in einem Browser (Handy oder PC) +- Melde dich an und gib den angezeigten Code ein +- Bestaetige die Autorisierung +- ViewIT erkennt die Freigabe automatisch – fertig! + +Die Autorisierung bleibt dauerhaft gespeichert. Du musst das nur einmal machen. + + +Einstellungen + +- Trakt aktivieren: Schaltet alle Trakt-Funktionen ein oder aus +- Trakt autorisieren: Verbindet ViewIT mit deinem Trakt-Konto +- Scrobbling aktivieren: Sendet automatisch an Trakt, was du gerade schaust +- Geschaute Serien automatisch zur Watchlist hinzufuegen: Fuegt Serien/Filme beim Schauen automatisch zu deiner Trakt-Watchlist hinzu, damit sie bei "Upcoming" erscheinen + + +Menues im Hauptmenue + +Wenn Trakt aktiviert und autorisiert ist, erscheinen im ViewIT-Hauptmenue folgende Eintraege: + + +Weiterschauen + +Zeigt Serien, bei denen du mittendrin aufgehoert hast. Praktisch um schnell dort weiterzumachen, wo du zuletzt warst. + + +Trakt Upcoming + +Zeigt neue Episoden der naechsten 14 Tage fuer alle Serien in deiner Trakt-Watchlist. Die Ansicht ist nach Datum gruppiert: + +- Heute – Episoden, die heute erscheinen +- Morgen – Episoden von morgen +- Wochentag – z.B. "Mittwoch", "Donnerstag" +- Wochentag + Datum – ab naechster Woche, z.B. "Montag 24.03." + +Jeder Eintrag zeigt Serienname, Staffel/Episode und Episodentitel, z.B.: + Game of Thrones – S02E05: The Wolf and the Lion + +Damit eine Serie hier erscheint, muss sie in deiner Trakt-Watchlist sein. Du kannst Serien auf drei Wegen hinzufuegen: +- Direkt auf trakt.tv +- Ueber das Kontextmenue in der Trakt History (siehe unten) +- Automatisch beim Schauen (Einstellung "Geschaute Serien automatisch zur Watchlist hinzufuegen") + + +Trakt Watchlist + +Zeigt alle Titel in deiner Trakt-Watchlist, unterteilt in Filme und Serien. +Ein Klick auf einen Eintrag fuehrt zur Staffel-/Episodenauswahl in ViewIT. + + +Trakt History + +Zeigt deine zuletzt geschauten Episoden und Filme (seitenweise, neueste zuerst). Jeder Eintrag zeigt Serienname mit Staffel, Episode, Episodentitel und Poster. + +Kontextmenue (lange druecken oder Taste "C"): +- "Zur Trakt-Watchlist hinzufuegen" – Fuegt die Serie/den Film zu deiner Watchlist hinzu, damit kuenftige Episoden bei "Upcoming" erscheinen + + +Scrobbling + +Scrobbling bedeutet, dass ViewIT automatisch an Trakt meldet was du schaust: + +- Du startest eine Episode oder einen Film in ViewIT +- ViewIT sendet "Start" an Trakt (die Episode erscheint als "Watching" in deinem Profil) +- Wenn die Wiedergabe endet, sendet ViewIT "Stop" mit dem Fortschritt +- Hat der Fortschritt mindestens 80% erreicht, markiert Trakt die Episode als gesehen + +Das passiert vollautomatisch im Hintergrund – du musst nichts tun. + + +Haeufige Fragen + +Warum erscheint eine Serie nicht bei "Upcoming"? +Die Serie muss in deiner Trakt-Watchlist sein. Fuege sie ueber die Trakt History (Kontextmenue) oder direkt auf trakt.tv hinzu. + +Warum wird eine Episode nicht als gesehen markiert? +Trakt markiert Episoden erst als gesehen, wenn mindestens ca. 80% geschaut wurden. Wenn du vorher abbrichst, wird sie nicht als gesehen gezaehlt. + +Kann ich Trakt auf mehreren Geraeten nutzen? +Ja. Autorisiere ViewIT auf jedem Geraet und alle teilen denselben Schauverlauf ueber dein Trakt-Konto. + +Muss ich online sein? +Ja, Trakt benoetigt eine Internetverbindung. Ohne Verbindung funktioniert die Wiedergabe weiterhin, aber Scrobbling und Trakt-Menues sind nicht verfuegbar.