Compare commits

...

12 Commits

13 changed files with 1133 additions and 90 deletions

4
.gitignore vendored
View File

@@ -19,3 +19,7 @@ __pycache__/
# Plugin runtime caches
/addon/plugins/*_cache.json
# Projektdokumentation (lokal)
/PROJECT_INDEX.md
/FUNCTION_MAP.md

View File

@@ -1,3 +1,51 @@
## 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
- dev: Uhrzeit aus Episodentitel entfernen, tvshow-Mediatype fix
## 0.1.78.0-dev - 2026-03-11
- dev: Trakt-Scrobbling fuer alle Wiedergabe-Pfade
## 0.1.77.5-dev - 2026-03-10
- dev: Max. Eintraege pro Seite Setting pro Plugin
## 0.1.77.0-dev - 2026-03-10
- dev: Changelog-Dialog nur anzeigen wenn Eintrag vorhanden
## 0.1.76.5-dev - 2026-03-10
- dev: Versionsfilter fuer 4-teilige Versionsnummern korrigiert
## 0.1.76.0-dev - 2026-03-10
- dev: bump to 0.1.76.0-dev aeltere Versionen im Update-Dialog, Release-Branch-Zuordnung, README ueberarbeitet

View File

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

View File

@@ -52,6 +52,24 @@ def _require_init() -> None:
print("[ViewIT/metadata] WARNUNG: metadata.init() wurde nicht aufgerufen Metadaten-Funktionen arbeiten mit Standardwerten!", file=sys.stderr)
def _resolve_tmdb_api_key(user_key: str) -> str:
"""Key aus ViewIT-Settings, installiertem Kodi-Scraper oder Community-Fallback."""
if user_key:
return user_key
if xbmcaddon:
for addon_id in (
"metadata.tvshows.themoviedb.org.python",
"metadata.themoviedb.org.python",
):
try:
key = xbmcaddon.Addon(addon_id).getSetting("tmdb_apikey")
if key:
return key
except RuntimeError:
pass
return "80246691939720672db3fc71c74e0ef2"
def init(
*,
get_setting_string: Callable[[str], str],
@@ -153,7 +171,7 @@ def tmdb_labels_and_art(title: str) -> tuple[dict[str, str], dict[str, str], lis
art: dict[str, str] = {}
cast: list[TmdbCastMember] = []
query = (title or "").strip()
api_key = _get_setting_string("tmdb_api_key").strip()
api_key = _resolve_tmdb_api_key(_get_setting_string("tmdb_api_key").strip())
log_requests = _get_setting_bool("tmdb_log_requests", default=False)
log_responses = _get_setting_bool("tmdb_log_responses", default=False)
if api_key:
@@ -295,7 +313,7 @@ def tmdb_episode_labels_and_art(*, title: str, season_label: str, episode_label:
season_key = (tmdb_id, season_number, language, flags)
cached_season = tmdb_cache_get(_TMDB_SEASON_CACHE, season_key)
if cached_season is None:
api_key = _get_setting_string("tmdb_api_key").strip()
api_key = _resolve_tmdb_api_key(_get_setting_string("tmdb_api_key").strip())
if not api_key:
return {"title": episode_label}, {}
log_requests = _get_setting_bool("tmdb_log_requests", default=False)
@@ -358,7 +376,7 @@ def tmdb_episode_cast(*, title: str, season_label: str, episode_label: str) -> l
if cached is not None:
return list(cached)
api_key = _get_setting_string("tmdb_api_key").strip()
api_key = _resolve_tmdb_api_key(_get_setting_string("tmdb_api_key").strip())
if not api_key:
tmdb_cache_set(_TMDB_EPISODE_CAST_CACHE, cache_key, [])
return []
@@ -398,7 +416,7 @@ def tmdb_season_labels_and_art(
show_plot = _get_setting_bool("tmdb_show_plot", default=True)
show_art = _get_setting_bool("tmdb_show_art", default=True)
flags = f"p{int(show_plot)}a{int(show_art)}"
api_key = _get_setting_string("tmdb_api_key").strip()
api_key = _resolve_tmdb_api_key(_get_setting_string("tmdb_api_key").strip())
log_requests = _get_setting_bool("tmdb_log_requests", default=False)
log_responses = _get_setting_bool("tmdb_log_responses", default=False)
log_fn = tmdb_file_log if (log_requests or log_responses) else None

View File

@@ -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)
@@ -1209,6 +1215,7 @@ UPDATE_HTTP_TIMEOUT_SEC = 8
UPDATE_ADDON_ID = "plugin.video.viewit"
RESOLVEURL_ADDON_ID = "script.module.resolveurl"
RESOLVEURL_AUTO_INSTALL_INTERVAL_SEC = 6 * 60 * 60
YTDLP_ADDON_ID = "script.module.yt-dlp"
def _selected_update_channel() -> int:
@@ -1418,7 +1425,7 @@ def _extract_changelog_section(changelog_text: str, version: str) -> str:
start = idx
break
if start < 0:
return f"Kein Changelog-Abschnitt fuer Version {wanted} gefunden."
return ""
end = len(lines)
for idx in range(start + 1, len(lines)):
@@ -1441,7 +1448,7 @@ def _fetch_changelog_for_channel(channel: int, version: str) -> str:
try:
text = _read_text_url(url)
except Exception:
return "Changelog konnte nicht geladen werden."
return ""
return _extract_changelog_section(text, version)
@@ -1681,6 +1688,106 @@ def _maybe_auto_install_resolveurl(action: str | None) -> None:
_ensure_resolveurl_installed(force=False, silent=True)
def _sync_tmdb_active_key_setting() -> None:
from core.metadata import _resolve_tmdb_api_key
raw_key = _get_setting_string("tmdb_api_key").strip()
active_key = _resolve_tmdb_api_key(raw_key)
if active_key:
masked = active_key[:6] + "" + active_key[-4:] if len(active_key) > 10 else active_key
else:
masked = "(kein)"
_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:
addon_version = _installed_addon_version_from_disk()
if addon_version == "0.0.0":
@@ -1692,7 +1799,9 @@ def _sync_update_version_settings() -> None:
addon_version = "0.0.0"
_set_setting_string("update_installed_version", addon_version)
_sync_resolveurl_status_setting()
_sync_ytdlp_status_setting()
_sync_update_channel_status_settings()
_sync_tmdb_active_key_setting()
def _show_root_menu() -> None:
@@ -1702,7 +1811,7 @@ def _show_root_menu() -> None:
# Update-Hinweis ganz oben wenn neuere Version verfügbar
installed = _get_setting_string("update_installed_version").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(
handle,
f"Update verfuegbar: {installed} -> {available}",
@@ -3176,8 +3285,9 @@ def _show_popular(plugin_name: str | None = None, page: int = 1) -> None:
def _show_new_titles(plugin_name: str, page: int = 1, *, action_name: str = "new_titles") -> None:
handle = _get_handle()
page_size = LIST_PAGE_SIZE
page = max(1, int(page or 1))
max_items_key = f"{(plugin_name or '').strip().casefold()}_max_page_items"
page_size = _get_setting_int(max_items_key, default=15) or LIST_PAGE_SIZE
plugin_name = (plugin_name or "").strip()
plugin = _discover_plugins().get(plugin_name)
@@ -3218,6 +3328,8 @@ def _show_new_titles(plugin_name: str, page: int = 1, *, action_name: str = "new
xbmcplugin.endOfDirectory(handle)
return
page_items = [str(t).strip() for t in page_items if t and str(t).strip()]
if page_size > 0 and len(page_items) > page_size:
page_items = page_items[:page_size]
page_items.sort(key=lambda value: value.casefold())
else:
try:
@@ -3284,15 +3396,16 @@ def _show_new_titles(plugin_name: str, page: int = 1, *, action_name: str = "new
meta = plugin_meta.get(title)
info_labels, art, cast = _merge_metadata(title, tmdb_info, tmdb_art, tmdb_cast, meta)
info_labels = dict(info_labels or {})
info_labels.setdefault("mediatype", "movie")
is_direct_play = bool(
plugin_name.casefold() == "einschalten"
and _get_setting_bool("einschalten_enable_playback", default=False)
)
info_labels.setdefault("mediatype", "movie" if is_direct_play else "tvshow")
playstate = _title_playstate(plugin_name, title)
info_labels = _apply_playstate_to_info(dict(info_labels), playstate)
display_label = _label_with_duration(title, info_labels)
display_label = _label_with_playstate(display_label, playstate)
direct_play = bool(
plugin_name.casefold() == "einschalten"
and _get_setting_bool("einschalten_enable_playback", default=False)
)
direct_play = is_direct_play
_add_directory_item(
handle,
display_label,
@@ -3378,8 +3491,6 @@ def _show_latest_episodes(plugin_name: str, page: int = 1) -> None:
playstate = _get_playstate(key)
label = f"{title} - S{season_number:02d}E{episode_number:02d}"
if airdate:
label = f"{label} ({airdate})"
label = _label_with_playstate(label, playstate)
info_labels: dict[str, object] = {
@@ -3732,12 +3843,13 @@ def _show_version_selector() -> None:
version = versions[selected]
changelog = _fetch_changelog_for_channel(channel, version)
viewer = getattr(xbmcgui.Dialog(), "textviewer", None)
if callable(viewer):
try:
viewer(f"Changelog {version}", changelog)
except Exception:
pass
if changelog:
viewer = getattr(xbmcgui.Dialog(), "textviewer", None)
if callable(viewer):
try:
viewer(f"Changelog {version}", changelog)
except Exception:
pass
action = xbmcgui.Dialog().select(
f"Version {version} installieren?",
@@ -3853,6 +3965,190 @@ def _resolve_stream_with_retry(plugin: BasisPlugin, link: str) -> str | None:
return final_link
def _is_inputstream_adaptive_available() -> bool:
"""Prueft ob inputstream.adaptive in Kodi installiert ist."""
try:
import xbmcaddon # type: ignore
xbmcaddon.Addon("inputstream.adaptive")
return True
except Exception:
return False
# ---------------------------------------------------------------------------
# Lokaler MPD-Manifest-Server fuer inputstream.adaptive
# ---------------------------------------------------------------------------
_mpd_server_instance = None
_mpd_server_port = 0
def _ensure_mpd_server() -> int:
"""Startet einen lokalen HTTP-Server der MPD-Manifeste serviert.
Gibt den Port zurueck. Der Server laeuft in einem Daemon-Thread.
"""
global _mpd_server_instance, _mpd_server_port
if _mpd_server_instance is not None:
return _mpd_server_port
import http.server
import socketserver
import threading
_pending_manifests: dict[str, str] = {}
class _ManifestHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self) -> None:
if "/manifest" in self.path:
key = self.path.split("key=")[-1].split("&")[0] if "key=" in self.path else ""
content = _pending_manifests.pop(key, "")
if content:
data = content.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/dash+xml")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
return
self.send_error(404)
def log_message(self, *_args: object) -> None:
pass # kein Logging
server = socketserver.TCPServer(("127.0.0.1", 0), _ManifestHandler)
_mpd_server_port = server.server_address[1]
_mpd_server_instance = server
# pending_manifests als Attribut am Server speichern
server._pending_manifests = _pending_manifests # type: ignore[attr-defined]
t = threading.Thread(target=server.serve_forever, daemon=True)
t.start()
_log(f"MPD-Server gestartet auf Port {_mpd_server_port}", xbmc.LOGDEBUG)
return _mpd_server_port
def _register_mpd_manifest(mpd_xml: str) -> str:
"""Registriert ein MPD-Manifest und gibt die lokale URL zurueck."""
import hashlib
port = _ensure_mpd_server()
key = hashlib.md5(mpd_xml.encode()).hexdigest()[:12]
if _mpd_server_instance is not None:
_mpd_server_instance._pending_manifests[key] = mpd_xml # type: ignore[attr-defined]
return f"http://127.0.0.1:{port}/plugin.video.viewit/manifest?key={key}"
def _play_dual_stream(
video_url: str,
audio_url: str,
*,
meta: dict[str, str] | None = None,
display_title: str | None = None,
info_labels: dict[str, str] | None = None,
art: dict[str, str] | None = None,
cast: list[TmdbCastMember] | None = None,
resolve_handle: int | None = None,
trakt_media: dict[str, object] | None = None,
) -> None:
"""Spielt getrennte Video+Audio-Streams via inputstream.adaptive.
Startet einen lokalen HTTP-Server der ein generiertes MPD-Manifest
serviert (gem. inputstream.adaptive Wiki: Integration + Custom Manifest).
Fallback auf Video-only wenn inputstream.adaptive nicht installiert.
"""
if not _is_inputstream_adaptive_available():
_log("inputstream.adaptive nicht verfuegbar Video-only Wiedergabe", xbmc.LOGWARNING)
_play_final_link(
video_url, display_title=display_title, info_labels=info_labels,
art=art, cast=cast, resolve_handle=resolve_handle, trakt_media=trakt_media,
)
return
from xml.sax.saxutils import escape as xml_escape
m = meta or {}
vcodec = m.get("vc", "avc1.640028")
acodec = m.get("ac", "mp4a.40.2")
w = m.get("w", "1920")
h = m.get("h", "1080")
fps = m.get("fps", "25")
vbr = m.get("vbr", "5000000")
abr = m.get("abr", "128000")
asr = m.get("asr", "44100")
ach = m.get("ach", "2")
dur = m.get("dur", "0")
dur_attr = ""
if dur and dur != "0":
dur_attr = f' mediaPresentationDuration="PT{dur}S"'
mpd_xml = (
'<?xml 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(
link: str,
*,
@@ -3863,6 +4159,25 @@ def _play_final_link(
resolve_handle: int | None = None,
trakt_media: dict[str, object] | None = None,
) -> None:
# Getrennte Video+Audio-Streams (yt-dlp): via inputstream.adaptive abspielen
audio_url = None
meta: dict[str, str] = {}
try:
from ytdlp_helper import split_video_audio
link, audio_url, meta = split_video_audio(link)
except Exception:
pass
if audio_url:
_play_dual_stream(
link, audio_url,
meta=meta,
display_title=display_title, info_labels=info_labels,
art=art, cast=cast, resolve_handle=resolve_handle,
trakt_media=trakt_media,
)
return
list_item = xbmcgui.ListItem(label=display_title or "", path=link)
try:
list_item.setProperty("IsPlayable", "true")
@@ -3909,21 +4224,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()
@@ -3934,12 +4255,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")),
@@ -4118,15 +4437,14 @@ def _play_episode(
title_key = (title or "").strip().casefold()
_tmdb_id = _tmdb_cache_get(_TMDB_ID_CACHE, title_key, 0)
_imdb_id = ""
trakt_media: dict[str, object] | None = None
_kind = _tmdb_cache_get(_MEDIA_TYPE_CACHE, title_key, "tv") if _tmdb_id else "tv"
if _tmdb_id:
_kind = _tmdb_cache_get(_MEDIA_TYPE_CACHE, title_key, "tv")
_imdb_id = _fetch_and_cache_imdb_id(title_key, _tmdb_id, _kind)
_set_trakt_ids_property(title, _tmdb_id, _imdb_id)
trakt_media = {
"title": title, "tmdb_id": _tmdb_id, "imdb_id": _imdb_id, "kind": _kind,
"season": season_number or 0, "episode": episode_number or 0,
}
trakt_media: dict[str, object] = {
"title": title, "tmdb_id": _tmdb_id, "imdb_id": _imdb_id, "kind": _kind,
"season": season_number or 0, "episode": episode_number or 0,
}
_play_final_link(
final_link,
@@ -4230,6 +4548,19 @@ def _play_episode_url(
if episode_number > 0:
info_labels["episode"] = str(episode_number)
display_title = _label_with_duration(display_title, info_labels)
title_key = (title or "").strip().casefold()
_tmdb_id = _tmdb_cache_get(_TMDB_ID_CACHE, title_key, 0)
_imdb_id = ""
_kind = _tmdb_cache_get(_MEDIA_TYPE_CACHE, title_key, "tv") if _tmdb_id else "tv"
if _tmdb_id:
_imdb_id = _fetch_and_cache_imdb_id(title_key, _tmdb_id, _kind)
_set_trakt_ids_property(title, _tmdb_id, _imdb_id)
trakt_media: dict[str, object] = {
"title": title, "tmdb_id": _tmdb_id, "imdb_id": _imdb_id, "kind": _kind,
"season": season_number or 0, "episode": episode_number or 0,
}
_play_final_link(
final_link,
display_title=display_title,
@@ -4237,6 +4568,7 @@ def _play_episode_url(
art=art,
cast=cast,
resolve_handle=resolve_handle,
trakt_media=trakt_media,
)
_track_playback_and_update_state_async(
_playstate_key(plugin_name=plugin_name, title=title, season=season_label, episode=episode_label)
@@ -4533,13 +4865,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
@@ -4656,6 +4986,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:
@@ -4668,13 +5007,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)
@@ -4694,7 +5033,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
@@ -4710,20 +5049,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,
@@ -4736,15 +5105,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)
@@ -4976,6 +5348,11 @@ def _route_install_resolveurl(params: dict[str, str]) -> None:
_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")
def _route_choose_source(params: dict[str, str]) -> None:
_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)
except Exception:
return []
return _parse_listing_hits(soup, query=query)
return _parse_listing_hits(soup)
def capabilities(self) -> set[str]:
return {"genres", "popular_series", "tags", "random"}
@@ -455,15 +455,24 @@ class DokuStreamsPlugin(BasisPlugin):
art = {"thumb": poster, "poster": poster}
return info, art, None
def series_url_for_title(self, title: str) -> Optional[str]:
return self._title_to_url.get((title or "").strip())
def remember_series_url(self, title: str, url: str) -> None:
title = (title or "").strip()
url = (url or "").strip()
if title and url:
self._title_to_url[title] = url
def seasons_for(self, title: str) -> List[str]:
title = (title or "").strip()
if not title or title not in self._title_to_url:
if not title:
return []
return ["Stream"]
def episodes_for(self, title: str, season: str) -> List[str]:
title = (title or "").strip()
if not title or title not in self._title_to_url:
if not title:
return []
return [title]
@@ -537,6 +546,14 @@ class DokuStreamsPlugin(BasisPlugin):
"""Folgt Redirects und versucht ResolveURL fuer Hoster-Links."""
if not link:
return None
# YouTube-URLs via yt-dlp aufloesen
from ytdlp_helper import extract_youtube_id, resolve_youtube_url
yt_id = extract_youtube_id(link)
if yt_id:
resolved = resolve_youtube_url(yt_id)
if resolved:
return resolved
return None
from plugin_helpers import resolve_via_resolveurl
resolved = resolve_via_resolveurl(link, fallback_to_link=False)
if resolved:

View File

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

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

@@ -29,7 +29,8 @@
</category>
<category label="TMDB Erweitert">
<setting id="tmdb_api_key" type="text" label="TMDB API Key" default="" />
<setting id="tmdb_api_key" type="text" label="TMDB API Key (optional)" default="" />
<setting id="tmdb_api_key_active" type="text" label="Aktiver TMDB API Key" default="" />
<setting id="tmdb_prefetch_concurrency" type="number" label="TMDB: gleichzeitige Anfragen (1-20)" default="6" />
<setting id="tmdb_show_cast" type="bool" label="TMDB Besetzung anzeigen" default="false" />
<setting id="tmdb_show_episode_cast" type="bool" label="TMDB Besetzung pro Episode anzeigen" default="false" />
@@ -38,6 +39,17 @@
<setting id="tmdb_log_responses" type="bool" label="TMDB API-Antworten loggen" default="false" />
</category>
<category label="Anzeige">
<setting id="filmpalast_max_page_items" type="number" label="Filmpalast: Max. Eintraege pro Seite" default="15" />
<setting id="topstreamfilm_max_page_items" type="number" label="TopStream: Max. Eintraege pro Seite" default="15" />
<setting id="aniworld_max_page_items" type="number" label="AniWorld: Max. Eintraege pro Seite" default="15" />
<setting id="netzkkino_max_page_items" type="number" label="Netzkino: Max. Eintraege pro Seite" default="15" />
<setting id="kkiste_max_page_items" type="number" label="KKiste: Max. Eintraege pro Seite" default="15" />
<setting id="hdfilme_max_page_items" type="number" label="HDFilme: Max. Eintraege pro Seite" default="15" />
<setting id="moflix_max_page_items" type="number" label="Moflix: Max. Eintraege pro Seite" default="15" />
<setting id="einschalten_max_page_items" type="number" label="Einschalten: Max. Eintraege pro Seite" default="15" />
</category>
<category label="Wiedergabe">
<setting id="autoplay_enabled" type="bool" label="Autoplay (bevorzugten Hoster automatisch waehlen)" default="false" />
<setting id="preferred_hoster" type="text" label="Bevorzugter Hoster" default="voe" />
@@ -66,10 +78,9 @@
<category label="Trakt">
<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_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_refresh_token" type="text" label="" default="" visible="false" />
<setting id="trakt_token_expires" type="text" label="" default="0" visible="false" />
@@ -111,4 +122,10 @@
<setting id="show_url_info_filmpalast" type="bool" label="Filmpalast: Aktuelle URL anzeigen" default="false" />
<setting id="log_errors_filmpalast" type="bool" label="Filmpalast: Fehler mitschreiben" default="false" />
</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>

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")
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