Compare commits

...

9 Commits

12 changed files with 1098 additions and 319 deletions

View File

@@ -1,3 +1,39 @@
## 0.1.83.0-dev - 2026-03-15
- dev: Trakt Performance, Suchfilter Phrase-Match, Debug-Settings Expert-Level
## 0.1.82.5-dev - 2026-03-15
- dev: Update-Versionsvergleich numerisch korrigiert
## 0.1.82.0-dev - 2026-03-14
- dev: HDFilme Plot in Rubrik Neuste anzeigen
## 0.1.81.5-dev - 2026-03-14
- dev: YouTube HD via inputstream.adaptive, DokuStreams Suche fix
## 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
## 0.1.80.0-dev - 2026-03-13
- dev: YouTube-Plugin: yt-dlp Suche, Bug-Fix Any-Import
## 0.1.79.5-dev - 2026-03-11
- dev: Changelog-Hook auf prepare-commit-msg umgestellt
## 0.1.79.0-dev - 2026-03-11
- dev: TMDB API-Key automatisch aus Kodi-Scraper ermitteln
## 0.1.78.5-dev - 2026-03-11 ## 0.1.78.5-dev - 2026-03-11
- dev: Uhrzeit aus Episodentitel entfernen, tvshow-Mediatype fix - dev: Uhrzeit aus Episodentitel entfernen, tvshow-Mediatype fix

View File

@@ -1,11 +1,12 @@
<?xml version='1.0' encoding='utf-8'?> <?xml version='1.0' encoding='utf-8'?>
<addon id="plugin.video.viewit" name="ViewIt" version="0.1.79.0-dev" provider-name="ViewIt"> <addon id="plugin.video.viewit" name="ViewIt" version="0.1.83.5-dev" provider-name="ViewIt">
<requires> <requires>
<import addon="xbmc.python" version="3.0.0" /> <import addon="xbmc.python" version="3.0.0" />
<import addon="script.module.requests" /> <import addon="script.module.requests" />
<import addon="script.module.beautifulsoup4" /> <import addon="script.module.beautifulsoup4" />
<import addon="script.module.resolveurl" version="5.1.0" /> <import addon="script.module.resolveurl" version="5.1.0" />
<import addon="script.trakt" optional="true" /> <import addon="script.trakt" optional="true" />
<import addon="script.module.yt-dlp" optional="true" />
</requires> </requires>
<extension point="xbmc.python.pluginsource" library="default.py"> <extension point="xbmc.python.pluginsource" library="default.py">
<provides>video</provides> <provides>video</provides>

View File

@@ -227,14 +227,14 @@ def _trakt_save_token(token) -> None:
addon.setSetting("trakt_token_expires", str(token.expires_at)) addon.setSetting("trakt_token_expires", str(token.expires_at))
TRAKT_CLIENT_ID = "5f1a46be11faa2ef286d6a5d4fbdcdfe3b19c87d3799c11af8cf25dae5b802e9"
TRAKT_CLIENT_SECRET = "7b694c47c13565197c3549c7467e92999f36fb2d118f7c185736ec960af22405"
def _trakt_get_client(): def _trakt_get_client():
"""Erstellt einen TraktClient falls client_id und client_secret konfiguriert sind.""" """Erstellt einen TraktClient mit den fest hinterlegten Credentials."""
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
from core.trakt import TraktClient 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: def _trakt_get_valid_token() -> str:
@@ -1167,6 +1167,7 @@ def _add_directory_item(
info_labels: dict[str, str] | None = None, info_labels: dict[str, str] | None = None,
art: dict[str, str] | None = None, art: dict[str, str] | None = None,
cast: list[TmdbCastMember] | None = None, cast: list[TmdbCastMember] | None = None,
context_menu: list[tuple[str, str]] | None = None,
) -> None: ) -> None:
"""Fuegt einen Eintrag (Folder oder Playable) in die Kodi-Liste ein.""" """Fuegt einen Eintrag (Folder oder Playable) in die Kodi-Liste ein."""
query: dict[str, str] = {"action": action} query: dict[str, str] = {"action": action}
@@ -1187,6 +1188,11 @@ def _add_directory_item(
setter(art) setter(art)
except Exception: except Exception:
pass pass
if context_menu:
try:
item.addContextMenuItems(context_menu)
except Exception:
pass
xbmcplugin.addDirectoryItem(handle=handle, url=url, listitem=item, isFolder=is_folder) xbmcplugin.addDirectoryItem(handle=handle, url=url, listitem=item, isFolder=is_folder)
@@ -1209,6 +1215,7 @@ UPDATE_HTTP_TIMEOUT_SEC = 8
UPDATE_ADDON_ID = "plugin.video.viewit" UPDATE_ADDON_ID = "plugin.video.viewit"
RESOLVEURL_ADDON_ID = "script.module.resolveurl" RESOLVEURL_ADDON_ID = "script.module.resolveurl"
RESOLVEURL_AUTO_INSTALL_INTERVAL_SEC = 6 * 60 * 60 RESOLVEURL_AUTO_INSTALL_INTERVAL_SEC = 6 * 60 * 60
YTDLP_ADDON_ID = "script.module.yt-dlp"
def _selected_update_channel() -> int: def _selected_update_channel() -> int:
@@ -1692,6 +1699,95 @@ def _sync_tmdb_active_key_setting() -> None:
_set_setting_string("tmdb_api_key_active", masked) _set_setting_string("tmdb_api_key_active", masked)
def _sync_ytdlp_status_setting() -> None:
status = "Installiert" if _is_addon_installed(YTDLP_ADDON_ID) else "Fehlt"
_set_setting_string("ytdlp_status", status)
def _fetch_ytdlp_zip_url() -> str:
"""Ermittelt die aktuellste ZIP-URL fuer script.module.yt-dlp von GitHub."""
api_url = "https://api.github.com/repos/lekma/script.module.yt-dlp/releases/latest"
try:
data = json.loads(_read_binary_url(api_url, timeout=10).decode("utf-8"))
for asset in data.get("assets", []):
url = asset.get("browser_download_url", "")
if url.endswith(".zip"):
return url
except Exception as exc:
_log(f"yt-dlp Release-URL nicht ermittelbar: {exc}", xbmc.LOGWARNING)
return ""
def _install_ytdlp_from_zip(zip_url: str) -> bool:
"""Laedt script.module.yt-dlp ZIP von GitHub und entpackt es in den Addons-Ordner."""
try:
zip_bytes = _read_binary_url(zip_url, timeout=60)
except Exception as exc:
_log(f"yt-dlp ZIP-Download fehlgeschlagen: {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"yt-dlp Entpacken fehlgeschlagen: {exc}", xbmc.LOGWARNING)
return False
builtin = getattr(xbmc, "executebuiltin", None)
if callable(builtin):
builtin("UpdateLocalAddons")
return _is_addon_installed(YTDLP_ADDON_ID)
def _ensure_ytdlp_installed(*, force: bool, silent: bool) -> bool:
if _is_addon_installed(YTDLP_ADDON_ID):
_sync_ytdlp_status_setting()
return True
zip_url = _fetch_ytdlp_zip_url()
if not zip_url:
_sync_ytdlp_status_setting()
if not silent:
xbmcgui.Dialog().notification(
"yt-dlp",
"Aktuelle Version nicht ermittelbar (GitHub nicht erreichbar?).",
xbmcgui.NOTIFICATION_ERROR,
5000,
)
return False
ok = _install_ytdlp_from_zip(zip_url)
_sync_ytdlp_status_setting()
if not silent:
if ok:
xbmcgui.Dialog().notification(
"yt-dlp",
"script.module.yt-dlp ist installiert.",
xbmcgui.NOTIFICATION_INFO,
4000,
)
else:
xbmcgui.Dialog().notification(
"yt-dlp",
"Installation fehlgeschlagen.",
xbmcgui.NOTIFICATION_ERROR,
5000,
)
return ok
def _sync_update_version_settings() -> None: def _sync_update_version_settings() -> None:
addon_version = _installed_addon_version_from_disk() addon_version = _installed_addon_version_from_disk()
if addon_version == "0.0.0": if addon_version == "0.0.0":
@@ -1703,6 +1799,7 @@ def _sync_update_version_settings() -> None:
addon_version = "0.0.0" addon_version = "0.0.0"
_set_setting_string("update_installed_version", addon_version) _set_setting_string("update_installed_version", addon_version)
_sync_resolveurl_status_setting() _sync_resolveurl_status_setting()
_sync_ytdlp_status_setting()
_sync_update_channel_status_settings() _sync_update_channel_status_settings()
_sync_tmdb_active_key_setting() _sync_tmdb_active_key_setting()
@@ -1714,7 +1811,7 @@ def _show_root_menu() -> None:
# Update-Hinweis ganz oben wenn neuere Version verfügbar # Update-Hinweis ganz oben wenn neuere Version verfügbar
installed = _get_setting_string("update_installed_version").strip() installed = _get_setting_string("update_installed_version").strip()
available = _get_setting_string("update_available_selected").strip() available = _get_setting_string("update_available_selected").strip()
if installed and available and available not in ("-", "", "0.0.0") and installed != available: if installed and available and available not in ("-", "", "0.0.0") and _version_sort_key(available) > _version_sort_key(installed):
_add_directory_item( _add_directory_item(
handle, handle,
f"Update verfuegbar: {installed} -> {available}", f"Update verfuegbar: {installed} -> {available}",
@@ -1841,6 +1938,8 @@ def _show_plugin_search_results(plugin_name: str, query: str) -> None:
pass pass
raise raise
results = _clean_search_titles([str(t).strip() for t in (results or []) if t and str(t).strip()]) results = _clean_search_titles([str(t).strip() for t in (results or []) if t and str(t).strip()])
from search_utils import matches_query as _mq
results = [r for r in results if _mq(query, title=r)]
results.sort(key=lambda value: value.casefold()) results.sort(key=lambda value: value.casefold())
use_source, show_tmdb, prefer_source = _metadata_policy( use_source, show_tmdb, prefer_source = _metadata_policy(
@@ -2111,6 +2210,8 @@ def _show_search_results(query: str) -> None:
_log(f"Suche fehlgeschlagen ({plugin_name}): {exc}", xbmc.LOGWARNING) _log(f"Suche fehlgeschlagen ({plugin_name}): {exc}", xbmc.LOGWARNING)
continue continue
results = _clean_search_titles([str(t).strip() for t in (results or []) if t and str(t).strip()]) results = _clean_search_titles([str(t).strip() for t in (results or []) if t and str(t).strip()])
from search_utils import matches_query as _mq
results = [r for r in results if _mq(query, title=r)]
_log(f"Treffer ({plugin_name}): {len(results)}", xbmc.LOGDEBUG) _log(f"Treffer ({plugin_name}): {len(results)}", xbmc.LOGDEBUG)
use_source, show_tmdb, prefer_source = _metadata_policy( use_source, show_tmdb, prefer_source = _metadata_policy(
plugin_name, plugin, allow_tmdb=_tmdb_enabled() plugin_name, plugin, allow_tmdb=_tmdb_enabled()
@@ -3868,6 +3969,190 @@ def _resolve_stream_with_retry(plugin: BasisPlugin, link: str) -> str | None:
return final_link 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 version="1.0" encoding="UTF-8"?>'
'<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" type="static"'
' minBufferTime="PT2S"'
' profiles="urn:mpeg:dash:profile:isoff-on-demand:2011"'
+ dur_attr + '>'
'<Period>'
'<AdaptationSet mimeType="video/mp4" contentType="video" subsegmentAlignment="true">'
'<Representation id="video" bandwidth="' + vbr + '"'
' codecs="' + xml_escape(vcodec) + '"'
' width="' + w + '" height="' + h + '"'
' frameRate="' + fps + '">'
'<BaseURL>' + xml_escape(video_url) + '</BaseURL>'
'</Representation>'
'</AdaptationSet>'
'<AdaptationSet mimeType="audio/mp4" contentType="audio" subsegmentAlignment="true">'
'<Representation id="audio" bandwidth="' + abr + '"'
' codecs="' + xml_escape(acodec) + '"'
' audioSamplingRate="' + asr + '">'
'<AudioChannelConfiguration'
' schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011"'
' value="' + ach + '"/>'
'<BaseURL>' + xml_escape(audio_url) + '</BaseURL>'
'</Representation>'
'</AdaptationSet>'
'</Period>'
'</MPD>'
)
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( def _play_final_link(
link: str, link: str,
*, *,
@@ -3878,6 +4163,25 @@ def _play_final_link(
resolve_handle: int | None = None, resolve_handle: int | None = None,
trakt_media: dict[str, object] | None = None, trakt_media: dict[str, object] | None = 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) list_item = xbmcgui.ListItem(label=display_title or "", path=link)
try: try:
list_item.setProperty("IsPlayable", "true") list_item.setProperty("IsPlayable", "true")
@@ -3924,21 +4228,27 @@ def _trakt_scrobble_start_async(media: dict[str, object]) -> None:
from core.trakt import TraktClient from core.trakt import TraktClient
except Exception: except Exception:
return 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() 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 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( client.scrobble_start(
access_token, access_token,
media_type=str(media.get("kind", "movie")), media_type=media_type,
title=str(media.get("title", "")), title=str(media.get("title", "")),
tmdb_id=int(media.get("tmdb_id", 0)), tmdb_id=tmdb_id,
imdb_id=str(media.get("imdb_id", "")), imdb_id=imdb_id,
season=int(media.get("season", 0)), season=int(media.get("season", 0)),
episode=int(media.get("episode", 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() threading.Thread(target=_do, daemon=True).start()
@@ -3949,12 +4259,10 @@ def _trakt_scrobble_stop_async(media: dict[str, object], progress: float = 100.0
from core.trakt import TraktClient from core.trakt import TraktClient
except Exception: except Exception:
return 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() 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 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( client.scrobble_stop(
access_token, access_token,
media_type=str(media.get("kind", "movie")), media_type=str(media.get("kind", "movie")),
@@ -4561,13 +4869,11 @@ def _trakt_authorize() -> None:
time.sleep(code.interval) time.sleep(code.interval)
from core.trakt import TraktClient from core.trakt import TraktClient
# Einzelversuch (kein internes Polling wir steuern die Schleife selbst) # Einzelversuch (kein internes Polling wir steuern die Schleife selbst)
client_id = _get_setting_string("trakt_client_id").strip() tmp_client = TraktClient(TRAKT_CLIENT_ID, TRAKT_CLIENT_SECRET, log=lambda m: _log(m, xbmc.LOGDEBUG))
client_secret = _get_setting_string("trakt_client_secret").strip()
tmp_client = TraktClient(client_id, client_secret, log=lambda m: _log(m, xbmc.LOGDEBUG))
status, payload = tmp_client._post("/oauth/device/token", { status, payload = tmp_client._post("/oauth/device/token", {
"code": code.device_code, "code": code.device_code,
"client_id": client_id, "client_id": TRAKT_CLIENT_ID,
"client_secret": client_secret, "client_secret": TRAKT_CLIENT_SECRET,
}) })
if status == 200 and isinstance(payload, dict): if status == 200 and isinstance(payload, dict):
from core.trakt import TraktToken from core.trakt import TraktToken
@@ -4608,10 +4914,11 @@ def _show_trakt_watchlist(media_type: str = "") -> None:
_set_content(handle, "tvshows") _set_content(handle, "tvshows")
items = client.get_watchlist(token, media_type=media_type) items = client.get_watchlist(token, media_type=media_type)
tmdb_prefetched = _tmdb_labels_and_art_bulk([i.title for i in items]) if _tmdb_enabled() else {}
for item in items: for item in items:
label = f"{item.title} ({item.year})" if item.year else item.title label = f"{item.title} ({item.year})" if item.year else item.title
tmdb_info, art, _ = _tmdb_labels_and_art(item.title) tmdb_info, art, _ = tmdb_prefetched.get(item.title, ({}, {}, []))
info_labels: dict[str, object] = dict(tmdb_info) info_labels: dict[str, object] = dict(tmdb_info)
info_labels["title"] = label info_labels["title"] = label
info_labels["tvshowtitle"] = item.title info_labels["tvshowtitle"] = item.title
@@ -4619,16 +4926,7 @@ def _show_trakt_watchlist(media_type: str = "") -> None:
info_labels["year"] = item.year info_labels["year"] = item.year
info_labels["mediatype"] = "tvshow" info_labels["mediatype"] = "tvshow"
match = _trakt_find_in_plugins(item.title) _add_directory_item(handle, label, "search", {"query": item.title}, is_folder=True, info_labels=info_labels, art=art)
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: if not items:
xbmcgui.Dialog().notification("Trakt", "Watchlist ist leer.", xbmcgui.NOTIFICATION_INFO, 3000) xbmcgui.Dialog().notification("Trakt", "Watchlist ist leer.", xbmcgui.NOTIFICATION_INFO, 3000)
xbmcplugin.endOfDirectory(handle) xbmcplugin.endOfDirectory(handle)
@@ -4647,6 +4945,7 @@ def _show_trakt_history(page: int = 1) -> None:
_set_content(handle, "episodes") _set_content(handle, "episodes")
items = client.get_history(token, page=page, limit=LIST_PAGE_SIZE) items = client.get_history(token, page=page, limit=LIST_PAGE_SIZE)
tmdb_prefetched = _tmdb_labels_and_art_bulk(list(dict.fromkeys(i.title for i in items))) if _tmdb_enabled() else {}
for item in items: for item in items:
is_episode = item.media_type == "episode" and item.season and item.episode is_episode = item.media_type == "episode" and item.season and item.episode
@@ -4667,7 +4966,7 @@ def _show_trakt_history(page: int = 1) -> None:
art["fanart"] = item.show_fanart art["fanart"] = item.show_fanart
if item.show_poster: if item.show_poster:
art["poster"] = item.show_poster art["poster"] = item.show_poster
_, tmdb_art, _ = _tmdb_labels_and_art(item.title) _, tmdb_art, _ = tmdb_prefetched.get(item.title, ({}, {}, []))
for _k, _v in tmdb_art.items(): for _k, _v in tmdb_art.items():
art.setdefault(_k, _v) art.setdefault(_k, _v)
@@ -4684,25 +4983,17 @@ def _show_trakt_history(page: int = 1) -> None:
info_labels["plot"] = item.episode_overview info_labels["plot"] = item.episode_overview
info_labels["mediatype"] = "episode" if is_episode else "tvshow" 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 # Navigation: Episoden direkt abspielen, Serien zur Staffelauswahl
match = _trakt_find_in_plugins(item.title) _add_directory_item(handle, label, "search", {"query": item.title}, is_folder=True, info_labels=info_labels, art=art, context_menu=ctx or None)
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: if len(items) >= LIST_PAGE_SIZE:
_add_directory_item(handle, "Naechste Seite >>", "trakt_history", {"page": str(page + 1)}, is_folder=True) _add_directory_item(handle, "Naechste Seite >>", "trakt_history", {"page": str(page + 1)}, is_folder=True)
@@ -4722,7 +5013,7 @@ def _show_trakt_upcoming() -> None:
return return
xbmcplugin.setPluginCategory(handle, "Trakt: Upcoming") xbmcplugin.setPluginCategory(handle, "Trakt: Upcoming")
_set_content(handle, "episodes") _set_content(handle, "tvshows")
try: try:
from core.trakt import TraktCalendarItem as _TCI # noqa: F401 from core.trakt import TraktCalendarItem as _TCI # noqa: F401
@@ -4738,20 +5029,54 @@ def _show_trakt_upcoming() -> None:
xbmcplugin.endOfDirectory(handle) xbmcplugin.endOfDirectory(handle)
return 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: for item in items:
# Datum aufbereiten: ISO -> lesbares Datum airdate = today
airdate = ""
if item.first_aired: if item.first_aired:
try: try:
from datetime import datetime, timezone
dt = datetime.fromisoformat(item.first_aired.replace("Z", "+00:00")) 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: except Exception:
airdate = item.first_aired[:10] pass
dated_items.append((airdate, item))
# TMDB-Artwork fuer alle Serien parallel prefetchen (dedupliziert)
show_titles = list(dict.fromkeys(item.show_title for _, item in dated_items))
tmdb_prefetched = _tmdb_labels_and_art_bulk(show_titles) if _tmdb_enabled() else {}
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}" label = f"{item.show_title} \u2013 S{item.season:02d}E{item.episode:02d}"
if airdate: if ep_title:
label = f"{label} ({airdate})" label = f"{label}: {ep_title}"
info_labels: dict[str, object] = { info_labels: dict[str, object] = {
"title": label, "title": label,
@@ -4764,31 +5089,24 @@ def _show_trakt_upcoming() -> None:
info_labels["year"] = item.show_year info_labels["year"] = item.show_year
if item.episode_overview: if item.episode_overview:
info_labels["plot"] = 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] = {} 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: if item.show_poster:
art["thumb"] = item.show_poster
art["poster"] = item.show_poster art["poster"] = item.show_poster
_, tmdb_art, _ = _tmdb_labels_and_art(item.show_title) if item.episode_thumb:
art["fanart"] = item.episode_thumb
elif item.show_fanart:
art["fanart"] = item.show_fanart
_, tmdb_art, _ = tmdb_prefetched.get(item.show_title, ({}, {}, []))
for _k, _v in tmdb_art.items(): for _k, _v in tmdb_art.items():
art.setdefault(_k, _v) art.setdefault(_k, _v)
match = _trakt_find_in_plugins(item.show_title) action = "search"
if match: params: dict[str, str] = {"query": item.show_title}
plugin_name, matched_title = match
action = "episodes"
params: dict[str, str] = {
"plugin": plugin_name,
"title": matched_title,
"season": f"Staffel {item.season}",
}
else:
action = "search"
params = {"query": item.show_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)
@@ -4827,47 +5145,18 @@ def _show_trakt_continue_watching() -> None:
xbmcplugin.endOfDirectory(handle) xbmcplugin.endOfDirectory(handle)
return return
# TMDB-Artwork fuer alle Serien parallel prefetchen
tmdb_prefetched = _tmdb_labels_and_art_bulk(list(seen.keys())) if _tmdb_enabled() else {}
for last in seen.values(): for last in seen.values():
next_season = last.season next_season = last.season
next_ep = last.episode + 1 next_ep = last.episode + 1
match = _trakt_find_in_plugins(last.title)
# Wenn kein Plugin-Match: Suchaktion anbieten (kein Episode-Overflow-Problem)
if not match:
label = f"{last.title} \u2013 S{next_season:02d}E{next_ep:02d}"
sub = f"(zuletzt: S{last.season:02d}E{last.episode:02d})"
display_label = f"{label} {sub}"
info_labels: dict[str, object] = {
"title": display_label,
"tvshowtitle": last.title,
"mediatype": "episode",
}
if last.year:
info_labels["year"] = last.year
_, art, _ = _tmdb_labels_and_art(last.title)
_add_directory_item(handle, display_label, "search", {"query": last.title}, is_folder=True, info_labels=info_labels, art=art)
continue
plugin_name, matched_title = match
# Prüfe ob die nächste Episode im Plugin tatsächlich existiert
plugin = _discover_plugins().get(plugin_name)
episodes_getter = getattr(plugin, "episodes_for_season", None) if plugin else None
if callable(episodes_getter):
try:
ep_list = episodes_getter(matched_title, next_season) or []
if next_ep > len(ep_list):
# Letzte Folge der Staffel war die letzte nächste Staffel, Folge 1
next_season += 1
next_ep = 1
except Exception:
pass
label = f"{last.title} \u2013 S{next_season:02d}E{next_ep:02d}" label = f"{last.title} \u2013 S{next_season:02d}E{next_ep:02d}"
sub = f"(zuletzt: S{last.season:02d}E{last.episode:02d})" sub = f"(zuletzt: S{last.season:02d}E{last.episode:02d})"
display_label = f"{label} {sub}" display_label = f"{label} {sub}"
info_labels = { info_labels: dict[str, object] = {
"title": display_label, "title": display_label,
"tvshowtitle": last.title, "tvshowtitle": last.title,
"season": next_season, "season": next_season,
@@ -4877,14 +5166,8 @@ def _show_trakt_continue_watching() -> None:
if last.year: if last.year:
info_labels["year"] = last.year info_labels["year"] = last.year
_, art, _ = _tmdb_labels_and_art(last.title) _, art, _ = tmdb_prefetched.get(last.title, ({}, {}, []))
_add_directory_item(handle, display_label, "search", {"query": last.title}, is_folder=True, info_labels=info_labels, art=art)
params: dict[str, str] = {
"plugin": plugin_name,
"title": matched_title,
"season": f"Staffel {next_season}",
}
_add_directory_item(handle, display_label, "episodes", params, is_folder=True, info_labels=info_labels, art=art)
xbmcplugin.endOfDirectory(handle) xbmcplugin.endOfDirectory(handle)
@@ -5004,6 +5287,11 @@ def _route_install_resolveurl(params: dict[str, str]) -> None:
_ensure_resolveurl_installed(force=True, silent=False) _ensure_resolveurl_installed(force=True, silent=False)
@_router.route("install_ytdlp")
def _route_install_ytdlp(params: dict[str, str]) -> None:
_ensure_ytdlp_installed(force=True, silent=False)
@_router.route("choose_source") @_router.route("choose_source")
def _route_choose_source(params: dict[str, str]) -> None: def _route_choose_source(params: dict[str, str]) -> None:
_show_choose_source(params.get("title", ""), params.get("plugins", "")) _show_choose_source(params.get("title", ""), params.get("plugins", ""))

View File

@@ -286,7 +286,7 @@ class DokuStreamsPlugin(BasisPlugin):
soup = _get_soup(search_url, session=session) soup = _get_soup(search_url, session=session)
except Exception: except Exception:
return [] return []
return _parse_listing_hits(soup, query=query) return _parse_listing_hits(soup)
def capabilities(self) -> set[str]: def capabilities(self) -> set[str]:
return {"genres", "popular_series", "tags", "random"} return {"genres", "popular_series", "tags", "random"}
@@ -455,15 +455,24 @@ class DokuStreamsPlugin(BasisPlugin):
art = {"thumb": poster, "poster": poster} art = {"thumb": poster, "poster": poster}
return info, art, None 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]: def seasons_for(self, title: str) -> List[str]:
title = (title or "").strip() title = (title or "").strip()
if not title or title not in self._title_to_url: if not title:
return [] return []
return ["Stream"] return ["Stream"]
def episodes_for(self, title: str, season: str) -> List[str]: def episodes_for(self, title: str, season: str) -> List[str]:
title = (title or "").strip() title = (title or "").strip()
if not title or title not in self._title_to_url: if not title:
return [] return []
return [title] return [title]
@@ -537,6 +546,14 @@ class DokuStreamsPlugin(BasisPlugin):
"""Folgt Redirects und versucht ResolveURL fuer Hoster-Links.""" """Folgt Redirects und versucht ResolveURL fuer Hoster-Links."""
if not link: if not link:
return None 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 from plugin_helpers import resolve_via_resolveurl
resolved = resolve_via_resolveurl(link, fallback_to_link=False) resolved = resolve_via_resolveurl(link, fallback_to_link=False)
if resolved: if resolved:

View File

@@ -388,7 +388,7 @@ class HdfilmePlugin(BasisPlugin):
info: dict[str, str] = {"title": title} info: dict[str, str] = {"title": title}
art: dict[str, str] = {} art: dict[str, str] = {}
# Cache-Hit # Cache-Hit nur zurückgeben wenn Plot vorhanden (sonst Detailseite laden)
cached = self._title_meta.get(title) cached = self._title_meta.get(title)
if cached: if cached:
plot, poster = cached plot, poster = cached
@@ -396,7 +396,7 @@ class HdfilmePlugin(BasisPlugin):
info["plot"] = plot info["plot"] = plot
if poster: if poster:
art["thumb"] = art["poster"] = poster art["thumb"] = art["poster"] = poster
if info or art: if plot:
return info, art, None return info, art, None
# Detailseite laden # Detailseite laden

View File

@@ -57,7 +57,6 @@ else: # pragma: no cover
SETTING_BASE_URL = "serienstream_base_url" SETTING_BASE_URL = "serienstream_base_url"
SETTING_CATALOG_SEARCH = "serienstream_catalog_search"
DEFAULT_BASE_URL = "https://s.to" DEFAULT_BASE_URL = "https://s.to"
DEFAULT_PREFERRED_HOSTERS = ["voe"] DEFAULT_PREFERRED_HOSTERS = ["voe"]
DEFAULT_TIMEOUT = 20 DEFAULT_TIMEOUT = 20
@@ -80,10 +79,7 @@ HEADERS = {
SESSION_CACHE_TTL_SECONDS = 300 SESSION_CACHE_TTL_SECONDS = 300
SESSION_CACHE_PREFIX = "viewit.serienstream" SESSION_CACHE_PREFIX = "viewit.serienstream"
SESSION_CACHE_MAX_TITLE_URLS = 800 SESSION_CACHE_MAX_TITLE_URLS = 800
CATALOG_SEARCH_TTL_SECONDS = 600
CATALOG_SEARCH_CACHE_KEY = "catalog_index"
GENRE_LIST_PAGE_SIZE = 20 GENRE_LIST_PAGE_SIZE = 20
_CATALOG_INDEX_MEMORY: tuple[float, list["SeriesResult"]] = (0.0, [])
ProgressCallback = Optional[Callable[[str, int | None], Any]] ProgressCallback = Optional[Callable[[str, int | None], Any]]
@@ -575,8 +571,8 @@ def _search_series_server(query: str) -> list[SeriesResult]:
if not query: if not query:
return [] return []
base = _get_base_url() base = _get_base_url()
search_url = f"{base}/search?q={quote(query)}" search_url = f"{base}/suche?term={quote(query)}"
alt_url = f"{base}/suche?q={quote(query)}" alt_url = f"{base}/search?term={quote(query)}"
for url in (search_url, alt_url): for url in (search_url, alt_url):
try: try:
body = _get_html_simple(url) body = _get_html_simple(url)
@@ -606,158 +602,30 @@ def _search_series_server(query: str) -> list[SeriesResult]:
continue continue
seen_urls.add(url_abs) seen_urls.add(url_abs)
results.append(SeriesResult(title=title, description="", url=url_abs)) results.append(SeriesResult(title=title, description="", url=url_abs))
filtered = [r for r in results if _matches_query(query, title=r.title)]
if filtered:
return filtered
if results: if results:
return results return results
api_results = _search_series_api(query)
if api_results:
return api_results
return [] return []
def _extract_catalog_index_from_html(body: str, *, progress_callback: ProgressCallback = None) -> list[SeriesResult]:
items: list[SeriesResult] = []
if not body:
return items
seen_urls: set[str] = set()
item_re = re.compile(
r"<li[^>]*class=[\"'][^\"']*series-item[^\"']*[\"'][^>]*>(.*?)</li>",
re.IGNORECASE | re.DOTALL,
)
anchor_re = re.compile(r"<a[^>]+href=[\"']([^\"']+)[\"'][^>]*>(.*?)</a>", re.IGNORECASE | re.DOTALL)
data_search_re = re.compile(r"data-search=[\"']([^\"']*)[\"']", re.IGNORECASE)
for idx, match in enumerate(item_re.finditer(body), start=1):
if idx == 1 or idx % 200 == 0:
_emit_progress(progress_callback, f"Katalog parsen {idx}", 62)
block = match.group(0)
inner = match.group(1) or ""
anchor_match = anchor_re.search(inner)
if not anchor_match:
continue
href = (anchor_match.group(1) or "").strip()
url = _absolute_url(href)
if not url or "/serie/" not in url or "/staffel-" in url or "/episode-" in url:
continue
if url in seen_urls:
continue
seen_urls.add(url)
title_raw = anchor_match.group(2) or ""
title = unescape(re.sub(r"\s+", " ", _strip_tags(title_raw))).strip()
if not title:
continue
search_match = data_search_re.search(block)
description = (search_match.group(1) or "").strip() if search_match else ""
items.append(SeriesResult(title=title, description=description, url=url))
return items
def _catalog_index_from_soup(soup: BeautifulSoupT) -> list[SeriesResult]:
items: list[SeriesResult] = []
if not soup:
return items
seen_urls: set[str] = set()
for item in soup.select("li.series-item"):
anchor = item.find("a", href=True)
if not anchor:
continue
href = (anchor.get("href") or "").strip()
url = _absolute_url(href)
if not url or "/serie/" not in url or "/staffel-" in url or "/episode-" in url:
continue
if url in seen_urls:
continue
seen_urls.add(url)
title = (anchor.get_text(" ", strip=True) or "").strip()
if not title:
continue
description = (item.get("data-search") or "").strip()
items.append(SeriesResult(title=title, description=description, url=url))
return items
def _load_catalog_index_from_cache() -> Optional[list[SeriesResult]]:
global _CATALOG_INDEX_MEMORY
expires_at, cached = _CATALOG_INDEX_MEMORY
if cached and expires_at > time.time():
return list(cached)
raw = _session_cache_get(CATALOG_SEARCH_CACHE_KEY)
if not isinstance(raw, list):
return None
items: list[SeriesResult] = []
for entry in raw:
if not isinstance(entry, list) or len(entry) < 2:
continue
title = str(entry[0] or "").strip()
url = str(entry[1] or "").strip()
description = str(entry[2] or "") if len(entry) > 2 else ""
cover = str(entry[3] or "").strip() if len(entry) > 3 else ""
if title and url:
items.append(SeriesResult(title=title, description=description, url=url, cover=cover))
if items:
_CATALOG_INDEX_MEMORY = (time.time() + CATALOG_SEARCH_TTL_SECONDS, list(items))
return items or None
def _store_catalog_index_in_cache(items: list[SeriesResult]) -> None:
global _CATALOG_INDEX_MEMORY
if not items:
return
_CATALOG_INDEX_MEMORY = (time.time() + CATALOG_SEARCH_TTL_SECONDS, list(items))
payload: list[list[str]] = []
for entry in items:
if not entry.title or not entry.url:
continue
payload.append([entry.title, entry.url, entry.description, entry.cover])
_session_cache_set(CATALOG_SEARCH_CACHE_KEY, payload, ttl_seconds=CATALOG_SEARCH_TTL_SECONDS)
def search_series(query: str, *, progress_callback: ProgressCallback = None) -> list[SeriesResult]: def search_series(query: str, *, progress_callback: ProgressCallback = None) -> list[SeriesResult]:
"""Sucht Serien. Katalog-Suche (vollstaendig) oder API-Suche (max 10) je nach Setting.""" """Sucht Serien. Server-Suche (/suche?term=) zuerst, API als Fallback."""
_ensure_requests() _ensure_requests()
if not _normalize_search_text(query): if not _normalize_search_text(query):
return [] return []
use_catalog = _get_setting_bool(SETTING_CATALOG_SEARCH, default=True) # 1. Server-Suche (schnell, vollstaendig, direkte HTML-Suche)
_emit_progress(progress_callback, "Suche", 20)
if use_catalog:
_emit_progress(progress_callback, "Pruefe Such-Cache", 15)
cached = _load_catalog_index_from_cache()
if cached is not None:
matched_from_cache = [entry for entry in cached if entry.title and _matches_query(query, title=entry.title)]
_emit_progress(progress_callback, f"Cache-Treffer: {len(cached)}", 35)
if matched_from_cache:
return matched_from_cache
_emit_progress(progress_callback, "Lade Katalogseite", 42)
catalog_url = f"{_get_base_url()}/serien?by=genre"
items: list[SeriesResult] = []
try:
soup = _get_soup_simple(catalog_url)
items = _catalog_index_from_soup(soup)
except Exception:
body = _get_html_simple(catalog_url)
items = _extract_catalog_index_from_html(body, progress_callback=progress_callback)
if not items:
_emit_progress(progress_callback, "Fallback-Parser", 58)
soup = BeautifulSoup(body, "html.parser")
items = _catalog_index_from_soup(soup)
if items:
_store_catalog_index_in_cache(items)
_emit_progress(progress_callback, f"Filtere Treffer ({len(items)})", 70)
return [entry for entry in items if entry.title and _matches_query(query, title=entry.title)]
# API-Suche (primaer wenn Katalog deaktiviert, Fallback wenn Katalog leer)
_emit_progress(progress_callback, "API-Suche", 60)
api_results = _search_series_api(query)
if api_results:
_emit_progress(progress_callback, f"API-Treffer: {len(api_results)}", 80)
return api_results
_emit_progress(progress_callback, "Server-Suche", 85)
server_results = _search_series_server(query) server_results = _search_series_server(query)
if server_results: if server_results:
_emit_progress(progress_callback, f"Server-Treffer: {len(server_results)}", 95) return server_results
return [entry for entry in server_results if entry.title and _matches_query(query, title=entry.title)]
return [] # 2. API-Suche (Fallback, max 10 Ergebnisse)
_emit_progress(progress_callback, "API-Suche", 60)
return _search_series_api(query)
def parse_series_catalog(soup: BeautifulSoupT) -> dict[str, list[SeriesResult]]: def parse_series_catalog(soup: BeautifulSoupT) -> dict[str, list[SeriesResult]]:
@@ -1252,7 +1120,7 @@ class SerienstreamPlugin(BasisPlugin):
except Exception: except Exception:
continue continue
url = str(item.get("url") or "").strip() url = str(item.get("url") or "").strip()
if number <= 0 or not url: if number < 0 or not url:
continue continue
seasons.append(SeasonInfo(number=number, url=url, episodes=[])) seasons.append(SeasonInfo(number=number, url=url, episodes=[]))
if not seasons: if not seasons:
@@ -1794,6 +1662,8 @@ class SerienstreamPlugin(BasisPlugin):
@staticmethod @staticmethod
def _season_label(number: int) -> str: def _season_label(number: int) -> str:
if number == 0:
return "Filme"
return f"Staffel {number}" return f"Staffel {number}"
@staticmethod @staticmethod
@@ -1808,6 +1678,8 @@ class SerienstreamPlugin(BasisPlugin):
@staticmethod @staticmethod
def _parse_season_number(label: str) -> int | None: def _parse_season_number(label: str) -> int | None:
if (label or "").strip().casefold() == "filme":
return 0
digits = "".join(ch for ch in label if ch.isdigit()) digits = "".join(ch for ch in label if ch.isdigit())
if not digits: if not digits:
return None return None

View File

@@ -0,0 +1,238 @@
"""YouTube Plugin fuer ViewIT.
Suche und Wiedergabe von YouTube-Videos via HTML-Scraping und yt-dlp.
Benoetigt script.module.yt-dlp (optional).
Video-Eintraege werden als "Titel||VIDEO_ID" kodiert.
"""
from __future__ import annotations
import json
import re
from typing import Any, Callable, Dict, List, Optional, Set
try:
import requests
except ImportError:
requests = None # type: ignore
from plugin_interface import BasisPlugin
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
# ---------------------------------------------------------------------------
DEFAULT_TIMEOUT = 20
_SEP = "||" # Trennzeichen zwischen Titel und Video-ID
BASE_URL = "https://www.youtube.com"
HEADERS = {
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
),
"Accept-Language": "de-DE,de;q=0.9,en;q=0.8",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
}
ProgressCallback = Optional[Callable[[str, Optional[int]], Any]]
# ---------------------------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------------------------
def _encode(title: str, video_id: str) -> str:
return f"{title}{_SEP}{video_id}"
def _decode_id(entry: str) -> Optional[str]:
"""Extrahiert Video-ID aus einem kodierten Eintrag."""
if _SEP in entry:
return entry.split(_SEP, 1)[1].strip()
# Fallback: 11-Zeichen YouTube-ID am Ende
m = re.search(r"([A-Za-z0-9_-]{11})$", entry)
return m.group(1) if m else None
def _decode_title(entry: str) -> str:
if _SEP in entry:
return entry.split(_SEP, 1)[0].strip()
return entry
def _get_session() -> Any:
try:
from http_session_pool import get_requests_session
return get_requests_session("youtube", headers=HEADERS)
except Exception:
if requests:
s = requests.Session()
s.headers.update(HEADERS)
return s
return None
def _extract_yt_initial_data(html: str) -> Optional[dict]:
"""Extrahiert ytInitialData JSON aus dem HTML-Source."""
m = re.search(r"var ytInitialData\s*=\s*(\{.*?\});\s*(?:var |</script>)", html, re.DOTALL)
if not m:
# Alternativer Pattern
m = re.search(r"ytInitialData\s*=\s*(\{.+?\})\s*;", html, re.DOTALL)
if not m:
return None
try:
return json.loads(m.group(1))
except Exception:
return None
def _videos_from_search_data(data: dict) -> List[str]:
"""Extrahiert Video-Eintraege aus ytInitialData (Suchergebnisse)."""
results: List[str] = []
try:
contents = (
data
.get("contents", {})
.get("twoColumnSearchResultsRenderer", {})
.get("primaryContents", {})
.get("sectionListRenderer", {})
.get("contents", [])
)
for section in contents:
items = (
section
.get("itemSectionRenderer", {})
.get("contents", [])
)
for item in items:
vr = item.get("videoRenderer") or item.get("compactVideoRenderer")
if not vr:
continue
video_id = vr.get("videoId", "").strip()
if not video_id:
continue
title_runs = vr.get("title", {}).get("runs", [])
title = "".join(r.get("text", "") for r in title_runs).strip()
if not title:
title = vr.get("title", {}).get("simpleText", "").strip()
if title and video_id:
results.append(_encode(title, video_id))
except Exception as 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:
return []
ydl_opts = {"quiet": True, "no_warnings": True, "extract_flat": True}
try:
with YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(f"ytsearch{count}:{query}", download=False)
if not info:
return []
return [
_encode(e["title"], e["id"])
for e in (info.get("entries") or [])
if e.get("id") and e.get("title")
]
except Exception as exc:
_log(f"[YouTube] yt-dlp Suche Fehler: {exc}")
return []
def _fetch_search_videos(url: str) -> List[str]:
"""Holt Videos von einer YouTube-Suche via ytInitialData."""
session = _get_session()
if session is None:
return []
try:
resp = session.get(url, timeout=DEFAULT_TIMEOUT)
resp.raise_for_status()
data = _extract_yt_initial_data(resp.text)
if not data:
return []
return _videos_from_search_data(data)
except Exception as exc:
_log(f"[YouTube] _fetch_search_videos ({url}): {exc}")
return []
from ytdlp_helper import ensure_ytdlp_in_path, resolve_youtube_url
# ---------------------------------------------------------------------------
# Plugin
# ---------------------------------------------------------------------------
class YoutubePlugin(BasisPlugin):
name = "YouTube"
_SEASONS = ["Stream"]
def capabilities(self) -> Set[str]:
return set()
async def search_titles(
self,
query: str,
progress_callback: ProgressCallback = None,
) -> List[str]:
if not query.strip():
return []
# Primär: yt-dlp (robust, kein HTML-Scraping)
results = _search_with_ytdlp(query)
if results:
return results
# Fallback: HTML-Scraping
if requests is None:
return []
url = f"{BASE_URL}/results?search_query={requests.utils.quote(query)}" # type: ignore
return _fetch_search_videos(url)
def seasons_for(self, title: str) -> List[str]:
return list(self._SEASONS)
def episodes_for(self, title: str, season: str) -> List[str]:
if season == "Stream":
return [title]
return []
def stream_link_for(self, title: str, season: str, episode: str) -> Optional[str]:
video_id = _decode_id(episode) or _decode_id(title)
if not video_id:
return None
return resolve_youtube_url(video_id)
def resolve_stream_link(self, link: str) -> Optional[str]:
return link # bereits direkte URL
def metadata_for(self, title: str):
"""Thumbnail aus Video-ID ableiten."""
video_id = _decode_id(title)
clean_title = _decode_title(title)
info: Dict[str, str] = {"title": clean_title}
art: Dict[str, str] = {}
if video_id:
art["thumb"] = f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg"
art["poster"] = f"https://i.ytimg.com/vi/{video_id}/maxresdefault.jpg"
return info, art, None
Plugin = YoutubePlugin

View File

@@ -2,8 +2,7 @@
<settings> <settings>
<category label="Quellen"> <category label="Quellen">
<setting id="serienstream_base_url" type="text" label="SerienStream Basis-URL" default="https://s.to" /> <setting id="serienstream_base_url" type="text" label="SerienStream Basis-URL" default="https://s.to" />
<setting id="serienstream_catalog_search" type="bool" label="SerienStream: Katalog-Suche (mehr Ergebnisse, langsamer)" default="true" /> <setting id="aniworld_base_url" type="text" label="AniWorld Basis-URL" default="https://aniworld.to" />
<setting id="aniworld_base_url" type="text" label="AniWorld Basis-URL" default="https://aniworld.to" />
<setting id="topstream_base_url" type="text" label="TopStream Basis-URL" default="https://topstreamfilm.live" /> <setting id="topstream_base_url" type="text" label="TopStream Basis-URL" default="https://topstreamfilm.live" />
<setting id="einschalten_base_url" type="text" label="Einschalten Basis-URL" default="https://einschalten.in" /> <setting id="einschalten_base_url" type="text" label="Einschalten Basis-URL" default="https://einschalten.in" />
<setting id="filmpalast_base_url" type="text" label="Filmpalast Basis-URL" default="https://filmpalast.to" /> <setting id="filmpalast_base_url" type="text" label="Filmpalast Basis-URL" default="https://filmpalast.to" />
@@ -78,49 +77,54 @@
<category label="Trakt"> <category label="Trakt">
<setting id="trakt_enabled" type="bool" label="Trakt aktivieren" default="false" /> <setting id="trakt_enabled" type="bool" label="Trakt aktivieren" default="false" />
<setting id="trakt_client_id" type="text" label="Trakt Client ID" default="" />
<setting id="trakt_client_secret" type="text" label="Trakt Client Secret" default="" />
<setting id="trakt_auth" type="action" label="Trakt autorisieren" action="RunPlugin(plugin://plugin.video.viewit/?action=trakt_auth)" option="close" /> <setting id="trakt_auth" type="action" label="Trakt autorisieren" action="RunPlugin(plugin://plugin.video.viewit/?action=trakt_auth)" option="close" />
<setting id="trakt_scrobble" type="bool" label="Scrobbling aktivieren" default="true" /> <setting id="trakt_scrobble" type="bool" label="Scrobbling aktivieren" default="true" />
<setting id="trakt_auto_watchlist" type="bool" label="Geschaute Serien automatisch zur Watchlist hinzufuegen" default="false" />
<setting id="trakt_access_token" type="text" label="" default="" visible="false" /> <setting id="trakt_access_token" type="text" label="" default="" visible="false" />
<setting id="trakt_refresh_token" type="text" label="" default="" visible="false" /> <setting id="trakt_refresh_token" type="text" label="" default="" visible="false" />
<setting id="trakt_token_expires" type="text" label="" default="0" visible="false" /> <setting id="trakt_token_expires" type="text" label="" default="0" visible="false" />
</category> </category>
<category label="Debug Global"> <category label="Debug Global">
<setting id="debug_log_urls" type="bool" label="URLs mitschreiben (global)" default="false" /> <setting id="debug_log_urls" type="bool" label="URLs mitschreiben (global)" default="false" level="3" />
<setting id="debug_dump_html" type="bool" label="HTML speichern (global)" default="false" /> <setting id="debug_dump_html" type="bool" label="HTML speichern (global)" default="false" level="3" />
<setting id="debug_show_url_info" type="bool" label="Aktuelle URL anzeigen (global)" default="false" /> <setting id="debug_show_url_info" type="bool" label="Aktuelle URL anzeigen (global)" default="false" level="3" />
<setting id="debug_log_errors" type="bool" label="Fehler mitschreiben (global)" default="false" /> <setting id="debug_log_errors" type="bool" label="Fehler mitschreiben (global)" default="false" level="3" />
<setting id="log_max_mb" type="number" label="URL-Log: maximale Dateigroesse (MB)" default="5" /> <setting id="log_max_mb" type="number" label="URL-Log: maximale Dateigroesse (MB)" default="5" level="3" />
<setting id="log_max_files" type="number" label="URL-Log: Anzahl alter Dateien" default="3" /> <setting id="log_max_files" type="number" label="URL-Log: Anzahl alter Dateien" default="3" level="3" />
<setting id="dump_max_files" type="number" label="HTML: maximale Dateien pro Plugin" default="200" /> <setting id="dump_max_files" type="number" label="HTML: maximale Dateien pro Plugin" default="200" level="3" />
</category> </category>
<category label="Debug Quellen"> <category label="Debug Quellen">
<setting id="log_urls_serienstream" type="bool" label="SerienStream: URLs mitschreiben" default="false" /> <setting id="log_urls_serienstream" type="bool" label="SerienStream: URLs mitschreiben" default="false" level="3" />
<setting id="dump_html_serienstream" type="bool" label="SerienStream: HTML speichern" default="false" /> <setting id="dump_html_serienstream" type="bool" label="SerienStream: HTML speichern" default="false" level="3" />
<setting id="show_url_info_serienstream" type="bool" label="SerienStream: Aktuelle URL anzeigen" default="false" /> <setting id="show_url_info_serienstream" type="bool" label="SerienStream: Aktuelle URL anzeigen" default="false" level="3" />
<setting id="log_errors_serienstream" type="bool" label="SerienStream: Fehler mitschreiben" default="false" /> <setting id="log_errors_serienstream" type="bool" label="SerienStream: Fehler mitschreiben" default="false" level="3" />
<setting id="log_urls_aniworld" type="bool" label="AniWorld: URLs mitschreiben" default="false" /> <setting id="log_urls_aniworld" type="bool" label="AniWorld: URLs mitschreiben" default="false" level="3" />
<setting id="dump_html_aniworld" type="bool" label="AniWorld: HTML speichern" default="false" /> <setting id="dump_html_aniworld" type="bool" label="AniWorld: HTML speichern" default="false" level="3" />
<setting id="show_url_info_aniworld" type="bool" label="AniWorld: Aktuelle URL anzeigen" default="false" /> <setting id="show_url_info_aniworld" type="bool" label="AniWorld: Aktuelle URL anzeigen" default="false" level="3" />
<setting id="log_errors_aniworld" type="bool" label="AniWorld: Fehler mitschreiben" default="false" /> <setting id="log_errors_aniworld" type="bool" label="AniWorld: Fehler mitschreiben" default="false" level="3" />
<setting id="log_urls_topstreamfilm" type="bool" label="TopStream: URLs mitschreiben" default="false" /> <setting id="log_urls_topstreamfilm" type="bool" label="TopStream: URLs mitschreiben" default="false" level="3" />
<setting id="dump_html_topstreamfilm" type="bool" label="TopStream: HTML speichern" default="false" /> <setting id="dump_html_topstreamfilm" type="bool" label="TopStream: HTML speichern" default="false" level="3" />
<setting id="show_url_info_topstreamfilm" type="bool" label="TopStream: Aktuelle URL anzeigen" default="false" /> <setting id="show_url_info_topstreamfilm" type="bool" label="TopStream: Aktuelle URL anzeigen" default="false" level="3" />
<setting id="log_errors_topstreamfilm" type="bool" label="TopStream: Fehler mitschreiben" default="false" /> <setting id="log_errors_topstreamfilm" type="bool" label="TopStream: Fehler mitschreiben" default="false" level="3" />
<setting id="log_urls_einschalten" type="bool" label="Einschalten: URLs mitschreiben" default="false" /> <setting id="log_urls_einschalten" type="bool" label="Einschalten: URLs mitschreiben" default="false" level="3" />
<setting id="dump_html_einschalten" type="bool" label="Einschalten: HTML speichern" default="false" /> <setting id="dump_html_einschalten" type="bool" label="Einschalten: HTML speichern" default="false" level="3" />
<setting id="show_url_info_einschalten" type="bool" label="Einschalten: Aktuelle URL anzeigen" default="false" /> <setting id="show_url_info_einschalten" type="bool" label="Einschalten: Aktuelle URL anzeigen" default="false" level="3" />
<setting id="log_errors_einschalten" type="bool" label="Einschalten: Fehler mitschreiben" default="false" /> <setting id="log_errors_einschalten" type="bool" label="Einschalten: Fehler mitschreiben" default="false" level="3" />
<setting id="log_urls_filmpalast" type="bool" label="Filmpalast: URLs mitschreiben" default="false" /> <setting id="log_urls_filmpalast" type="bool" label="Filmpalast: URLs mitschreiben" default="false" level="3" />
<setting id="dump_html_filmpalast" type="bool" label="Filmpalast: HTML speichern" default="false" /> <setting id="dump_html_filmpalast" type="bool" label="Filmpalast: HTML speichern" default="false" level="3" />
<setting id="show_url_info_filmpalast" type="bool" label="Filmpalast: Aktuelle URL anzeigen" default="false" /> <setting id="show_url_info_filmpalast" type="bool" label="Filmpalast: Aktuelle URL anzeigen" default="false" level="3" />
<setting id="log_errors_filmpalast" type="bool" label="Filmpalast: Fehler mitschreiben" default="false" /> <setting id="log_errors_filmpalast" type="bool" label="Filmpalast: Fehler mitschreiben" default="false" level="3" />
</category> </category>
<category label="YouTube">
<setting id="youtube_quality" type="enum" label="YouTube Videoqualitaet" default="0" values="Beste|1080p|720p|480p|360p" />
<setting id="install_ytdlp" type="action" label="yt-dlp installieren/reparieren" action="RunPlugin(plugin://plugin.video.viewit/?action=install_ytdlp)" option="close" />
<setting id="ytdlp_status" type="text" label="yt-dlp Status" default="-" enable="false" />
</category>
</settings> </settings>

185
addon/ytdlp_helper.py Normal file
View File

@@ -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

111
docs/TRAKT.md Normal file
View File

@@ -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.

View File

@@ -15,19 +15,3 @@ msg=$(cat "$1")
updated_msg=$(echo "$msg" | sed -E "s/bump to [0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?[^ ]*/bump to ${version}/g") updated_msg=$(echo "$msg" | sed -E "s/bump to [0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?[^ ]*/bump to ${version}/g")
echo "$updated_msg" > "$1" echo "$updated_msg" > "$1"
today=$(date +%Y-%m-%d)
# Changelog-Eintrag aufbauen
{
echo "## ${version} - ${today}"
echo ""
while IFS= read -r line; do
[[ -z "$line" ]] && continue
echo "- ${line}"
done <<< "$updated_msg"
echo ""
cat CHANGELOG-DEV.md
} > /tmp/changelog_new.md
mv /tmp/changelog_new.md CHANGELOG-DEV.md
git add CHANGELOG-DEV.md

View File

@@ -0,0 +1,43 @@
#!/bin/bash
# prepare-commit-msg: Changelog-Eintrag in CHANGELOG-DEV.md schreiben (nur dev-Branch)
# Laeuft nach pre-commit (Version bereits gebumpt) und vor commit-msg.
# git add funktioniert hier zuverlässig für den aktuellen Commit.
branch=$(git symbolic-ref --short HEAD 2>/dev/null)
[[ "$branch" != "dev" ]] && exit 0
root=$(git rev-parse --show-toplevel)
cd "$root"
# Nur bei normalem Commit (nicht amend, merge, squash)
commit_type="${2:-}"
[[ "$commit_type" == "merge" || "$commit_type" == "squash" ]] && exit 0
# Aktuelle Version aus addon.xml (bereits vom pre-commit Hook hochgezaehlt)
version=$(grep -oP 'version="\K[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?[^"]*' addon/addon.xml | head -1)
[[ -z "$version" ]] && exit 0
# Commit-Message aus der Datei lesen (bereits vom User eingegeben oder per -m übergeben)
msg=$(cat "$1")
# Kommentarzeilen entfernen
msg=$(echo "$msg" | grep -v '^#' | sed '/^[[:space:]]*$/d' | head -1)
[[ -z "$msg" ]] && exit 0
today=$(date +%Y-%m-%d)
# Prüfen ob dieser Versions-Eintrag bereits existiert (Doppel-Eintrag verhindern)
if grep -q "^## ${version} " CHANGELOG-DEV.md 2>/dev/null; then
exit 0
fi
# Changelog-Eintrag aufbauen und prependen
{
echo "## ${version} - ${today}"
echo ""
echo "- ${msg}"
echo ""
cat CHANGELOG-DEV.md
} > /tmp/changelog_new.md
mv /tmp/changelog_new.md CHANGELOG-DEV.md
git add CHANGELOG-DEV.md