From 280a82f08b11369538482e4b3633600b0d39e8ce Mon Sep 17 00:00:00 2001 From: "itdrui.de" Date: Fri, 6 Feb 2026 12:29:12 +0100 Subject: [PATCH] Add Filmpalast A-Z browsing and document Gitea release upload --- README.md | 6 + addon/default.py | 154 +++++++++++++++++++++++ addon/plugins/filmpalast_plugin.py | 76 +++++++++++- scripts/publish_gitea_release.sh | 193 +++++++++++++++++++++++++++++ 4 files changed, 428 insertions(+), 1 deletion(-) create mode 100755 scripts/publish_gitea_release.sh diff --git a/README.md b/README.md index b87f3e6..79e8abd 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,12 @@ ViewIT ist ein Kodi‑Addon zum Durchsuchen und Abspielen von Inhalten der unter - Standard-URL: `http://127.0.0.1:8080/repo/addons.xml` - Optional eigene URL beim Build setzen: `REPO_BASE_URL=http://:/repo ./scripts/build_local_kodi_repo.sh` +## Gitea Release-Asset Upload +- ZIP bauen: `./scripts/build_kodi_zip.sh` +- Token setzen: `export GITEA_TOKEN=` +- Asset an Tag hochladen (erstellt Release bei Bedarf): `./scripts/publish_gitea_release.sh` +- Optional: `--tag v0.1.50 --asset dist/plugin.video.viewit-0.1.50.zip` + ## Entwicklung (kurz) - Hauptlogik: `addon/default.py` - Plugins: `addon/plugins/*_plugin.py` diff --git a/addon/default.py b/addon/default.py index 0584aac..3976da4 100644 --- a/addon/default.py +++ b/addon/default.py @@ -1018,6 +1018,9 @@ def _show_plugin_menu(plugin_name: str) -> None: if _plugin_has_capability(plugin, "genres"): _add_directory_item(handle, "Genres", "genres", {"plugin": plugin_name}, is_folder=True) + if _plugin_has_capability(plugin, "alpha"): + _add_directory_item(handle, "A-Z", "alpha_index", {"plugin": plugin_name}, is_folder=True) + if _plugin_has_capability(plugin, "popular_series"): _add_directory_item(handle, "Meist gesehen", "popular", {"plugin": plugin_name, "page": "1"}, is_folder=True) @@ -1729,6 +1732,149 @@ def _show_genre_titles_page(plugin_name: str, genre: str, page: int = 1) -> None xbmcplugin.endOfDirectory(handle) +def _show_alpha_index(plugin_name: str) -> None: + handle = _get_handle() + _log(f"A-Z laden: {plugin_name}") + plugin = _discover_plugins().get(plugin_name) + if plugin is None: + xbmcgui.Dialog().notification("A-Z", "Plugin nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) + xbmcplugin.endOfDirectory(handle) + return + getter = getattr(plugin, "alpha_index", None) + if not callable(getter): + xbmcgui.Dialog().notification("A-Z", "A-Z nicht verfügbar.", xbmcgui.NOTIFICATION_INFO, 3000) + xbmcplugin.endOfDirectory(handle) + return + try: + letters = list(getter() or []) + except Exception as exc: + _log(f"A-Z konnte nicht geladen werden ({plugin_name}): {exc}", xbmc.LOGWARNING) + xbmcgui.Dialog().notification("A-Z", "A-Z konnte nicht geladen werden.", xbmcgui.NOTIFICATION_INFO, 3000) + xbmcplugin.endOfDirectory(handle) + return + for letter in letters: + letter = str(letter).strip() + if not letter: + continue + _add_directory_item( + handle, + letter, + "alpha_titles_page", + {"plugin": plugin_name, "letter": letter, "page": "1"}, + is_folder=True, + ) + xbmcplugin.endOfDirectory(handle) + + +def _show_alpha_titles_page(plugin_name: str, letter: str, page: int = 1) -> None: + handle = _get_handle() + plugin = _discover_plugins().get(plugin_name) + if plugin is None: + xbmcgui.Dialog().notification("A-Z", "Plugin nicht gefunden.", xbmcgui.NOTIFICATION_INFO, 3000) + xbmcplugin.endOfDirectory(handle) + return + + page = max(1, int(page or 1)) + paging_getter = getattr(plugin, "titles_for_alpha_page", None) + if not callable(paging_getter): + xbmcgui.Dialog().notification("A-Z", "Paging nicht verfügbar.", xbmcgui.NOTIFICATION_INFO, 3000) + xbmcplugin.endOfDirectory(handle) + return + + total_pages = None + count_getter = getattr(plugin, "alpha_page_count", None) + if callable(count_getter): + try: + total_pages = int(count_getter(letter) or 1) + except Exception: + total_pages = None + if total_pages is not None: + page = min(page, max(1, total_pages)) + xbmcplugin.setPluginCategory(handle, f"{letter} ({page}/{total_pages})") + else: + xbmcplugin.setPluginCategory(handle, f"{letter} ({page})") + _set_content(handle, "movies" if (plugin_name or "").casefold() == "einschalten" else "tvshows") + + if page > 1: + _add_directory_item( + handle, + "Vorherige Seite", + "alpha_titles_page", + {"plugin": plugin_name, "letter": letter, "page": str(page - 1)}, + is_folder=True, + ) + + try: + titles = list(paging_getter(letter, page) or []) + except Exception as exc: + _log(f"A-Z Seite konnte nicht geladen werden ({plugin_name}/{letter} p{page}): {exc}", xbmc.LOGWARNING) + xbmcgui.Dialog().notification("A-Z", "Seite konnte nicht geladen werden.", xbmcgui.NOTIFICATION_INFO, 3000) + xbmcplugin.endOfDirectory(handle) + return + + titles = [str(t).strip() for t in titles if t and str(t).strip()] + titles.sort(key=lambda value: value.casefold()) + + show_tmdb = _get_setting_bool("tmdb_genre_metadata", default=False) + if titles: + if show_tmdb: + with _busy_dialog(): + tmdb_prefetched = _tmdb_labels_and_art_bulk(titles) + for title in titles: + info_labels, art, cast = tmdb_prefetched.get(title, _tmdb_labels_and_art(title)) + info_labels = dict(info_labels or {}) + info_labels.setdefault("mediatype", "tvshow") + if (info_labels.get("mediatype") or "").strip().casefold() == "tvshow": + info_labels.setdefault("tvshowtitle", title) + 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) + ) + _add_directory_item( + handle, + display_label, + "play_movie" if direct_play else "seasons", + {"plugin": plugin_name, "title": title, **_series_url_params(plugin, title)}, + is_folder=not direct_play, + info_labels=info_labels, + art=art, + cast=cast, + ) + else: + for title in titles: + playstate = _title_playstate(plugin_name, title) + direct_play = bool( + plugin_name.casefold() == "einschalten" + and _get_setting_bool("einschalten_enable_playback", default=False) + ) + _add_directory_item( + handle, + _label_with_playstate(title, playstate), + "play_movie" if direct_play else "seasons", + {"plugin": plugin_name, "title": title, **_series_url_params(plugin, title)}, + is_folder=not direct_play, + info_labels=_apply_playstate_to_info({"title": title}, playstate), + ) + + show_next = False + if total_pages is not None: + show_next = page < total_pages + + if show_next: + _add_directory_item( + handle, + "Nächste Seite", + "alpha_titles_page", + {"plugin": plugin_name, "letter": letter, "page": str(page + 1)}, + is_folder=True, + ) + xbmcplugin.endOfDirectory(handle) + + def _title_group_key(title: str) -> str: raw = (title or "").strip() if not raw: @@ -2708,6 +2854,14 @@ def run() -> None: params.get("genre", ""), _parse_positive_int(params.get("page", "1"), default=1), ) + elif action == "alpha_index": + _show_alpha_index(params.get("plugin", "")) + elif action == "alpha_titles_page": + _show_alpha_titles_page( + params.get("plugin", ""), + params.get("letter", ""), + _parse_positive_int(params.get("page", "1"), default=1), + ) elif action == "genre_series_group": _show_genre_series_group( params.get("plugin", ""), diff --git a/addon/plugins/filmpalast_plugin.py b/addon/plugins/filmpalast_plugin.py index 19097ee..9cd11e8 100644 --- a/addon/plugins/filmpalast_plugin.py +++ b/addon/plugins/filmpalast_plugin.py @@ -227,6 +227,8 @@ class FilmpalastPlugin(BasisPlugin): self._hoster_cache: Dict[str, Dict[str, str]] = {} self._genre_to_url: Dict[str, str] = {} self._genre_page_count_cache: Dict[str, int] = {} + self._alpha_to_url: Dict[str, str] = {} + self._alpha_page_count_cache: Dict[str, int] = {} self._requests_available = REQUESTS_AVAILABLE self._default_preferred_hosters: List[str] = list(DEFAULT_PREFERRED_HOSTERS) self._preferred_hosters: List[str] = list(self._default_preferred_hosters) @@ -495,7 +497,79 @@ class FilmpalastPlugin(BasisPlugin): return max_page def capabilities(self) -> set[str]: - return {"genres"} + return {"genres", "alpha"} + + def _parse_alpha_links(self, soup: BeautifulSoupT) -> Dict[str, str]: + alpha: Dict[str, str] = {} + if not soup: + return alpha + for anchor in soup.select("section#movietitle a[href], #movietitle a[href], aside #movietitle a[href]"): + name = (anchor.get_text(" ", strip=True) or "").strip() + href = (anchor.get("href") or "").strip() + if not name or not href: + continue + if "/search/alpha/" not in href: + continue + if name in alpha: + continue + alpha[name] = _absolute_url(href) + return alpha + + def alpha_index(self) -> List[str]: + if not self._requests_available: + return [] + if self._alpha_to_url: + return list(self._alpha_to_url.keys()) + try: + soup = _get_soup(_absolute_url("/"), session=get_requests_session("filmpalast", headers=HEADERS)) + except Exception: + return [] + parsed = self._parse_alpha_links(soup) + if parsed: + self._alpha_to_url = dict(parsed) + return list(self._alpha_to_url.keys()) + + def alpha_page_count(self, letter: str) -> int: + letter = (letter or "").strip() + if not letter: + return 1 + if letter in self._alpha_page_count_cache: + return max(1, int(self._alpha_page_count_cache.get(letter, 1))) + if not self._alpha_to_url: + self.alpha_index() + base_url = self._alpha_to_url.get(letter, "") + if not base_url: + return 1 + try: + soup = _get_soup(base_url, session=get_requests_session("filmpalast", headers=HEADERS)) + except Exception: + return 1 + pages = self._extract_last_page(soup) + self._alpha_page_count_cache[letter] = max(1, pages) + return self._alpha_page_count_cache[letter] + + def titles_for_alpha_page(self, letter: str, page: int) -> List[str]: + letter = (letter or "").strip() + if not letter or not self._requests_available: + return [] + if not self._alpha_to_url: + self.alpha_index() + base_url = self._alpha_to_url.get(letter, "") + if not base_url: + return [] + page = max(1, int(page or 1)) + url = base_url if page == 1 else urljoin(base_url.rstrip("/") + "/", f"page/{page}") + try: + soup = _get_soup(url, session=get_requests_session("filmpalast", headers=HEADERS)) + except Exception: + return [] + hits = self._parse_listing_hits(soup) + return self._apply_hits_to_title_index(hits) + + def titles_for_alpha(self, letter: str) -> List[str]: + titles = self.titles_for_alpha_page(letter, 1) + titles.sort(key=lambda value: value.casefold()) + return titles def genres(self) -> List[str]: if not self._requests_available: diff --git a/scripts/publish_gitea_release.sh b/scripts/publish_gitea_release.sh new file mode 100755 index 0000000..3cb2031 --- /dev/null +++ b/scripts/publish_gitea_release.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ADDON_XML="${ROOT_DIR}/addon/addon.xml" +DEFAULT_NOTES="Automatischer Release-Upload aus ViewIT Build." + +TAG="" +ASSET_PATH="" +TITLE="" +NOTES="${DEFAULT_NOTES}" +DRY_RUN="0" + +while [[ $# -gt 0 ]]; do + case "$1" in + --tag) + TAG="${2:-}" + shift 2 + ;; + --asset) + ASSET_PATH="${2:-}" + shift 2 + ;; + --title) + TITLE="${2:-}" + shift 2 + ;; + --notes) + NOTES="${2:-}" + shift 2 + ;; + --dry-run) + DRY_RUN="1" + shift + ;; + *) + echo "Unbekanntes Argument: $1" >&2 + exit 1 + ;; + esac +done + +if [[ ! -f "${ADDON_XML}" ]]; then + echo "Missing: ${ADDON_XML}" >&2 + exit 1 +fi + +read -r ADDON_ID ADDON_VERSION < <(python3 - "${ADDON_XML}" <<'PY' +import sys +import xml.etree.ElementTree as ET +root = ET.parse(sys.argv[1]).getroot() +print(root.attrib.get("id", "plugin.video.viewit"), root.attrib.get("version", "0.0.0")) +PY +) + +if [[ -z "${TAG}" ]]; then + TAG="v${ADDON_VERSION}" +fi + +if [[ -z "${ASSET_PATH}" ]]; then + ASSET_PATH="${ROOT_DIR}/dist/${ADDON_ID}-${ADDON_VERSION}.zip" +fi + +if [[ ! -f "${ASSET_PATH}" ]]; then + echo "Asset nicht gefunden, baue ZIP: ${ASSET_PATH}" + "${ROOT_DIR}/scripts/build_kodi_zip.sh" >/dev/null +fi + +if [[ ! -f "${ASSET_PATH}" ]]; then + echo "Asset fehlt nach Build: ${ASSET_PATH}" >&2 + exit 1 +fi + +if [[ -z "${TITLE}" ]]; then + TITLE="ViewIT ${TAG}" +fi + +REMOTE_URL="$(git -C "${ROOT_DIR}" remote get-url origin)" + +read -r BASE_URL OWNER REPO < <(python3 - "${REMOTE_URL}" <<'PY' +import re +import sys +u = sys.argv[1].strip() +m = re.match(r"^https?://([^/]+)/([^/]+)/([^/.]+)(?:\.git)?/?$", u) +if not m: + raise SystemExit("Origin-URL muss https://host/owner/repo(.git) sein.") +host, owner, repo = m.group(1), m.group(2), m.group(3) +print(f"https://{host}", owner, repo) +PY +) + +API_BASE="${BASE_URL}/api/v1/repos/${OWNER}/${REPO}" +ASSET_NAME="$(basename "${ASSET_PATH}")" + +if [[ "${DRY_RUN}" == "1" ]]; then + echo "[DRY-RUN] API: ${API_BASE}" + echo "[DRY-RUN] Tag: ${TAG}" + echo "[DRY-RUN] Asset: ${ASSET_PATH}" + exit 0 +fi + +if [[ -z "${GITEA_TOKEN:-}" ]]; then + echo "Bitte GITEA_TOKEN setzen." >&2 + exit 1 +fi + +tmp_json="$(mktemp)" +tmp_http="$(mktemp)" +trap 'rm -f "${tmp_json}" "${tmp_http}"' EXIT + +urlenc() { + python3 - "$1" <<'PY' +import sys +from urllib.parse import quote +print(quote(sys.argv[1], safe="")) +PY +} + +tag_enc="$(urlenc "${TAG}")" +auth_header="Authorization: token ${GITEA_TOKEN}" + +http_code="$(curl -sS -H "${auth_header}" -o "${tmp_json}" -w "%{http_code}" "${API_BASE}/releases/tags/${tag_enc}")" + +if [[ "${http_code}" == "200" ]]; then + RELEASE_ID="$(python3 - "${tmp_json}" <<'PY' +import json,sys +print(json.load(open(sys.argv[1], encoding="utf-8"))["id"]) +PY +)" +elif [[ "${http_code}" == "404" ]]; then + payload="$(python3 - "${TAG}" "${TITLE}" "${NOTES}" <<'PY' +import json,sys +print(json.dumps({ + "tag_name": sys.argv[1], + "name": sys.argv[2], + "body": sys.argv[3], + "draft": False, + "prerelease": False +})) +PY +)" + http_code_create="$(curl -sS -X POST -H "${auth_header}" -H "Content-Type: application/json" -d "${payload}" -o "${tmp_json}" -w "%{http_code}" "${API_BASE}/releases")" + if [[ "${http_code_create}" != "201" ]]; then + echo "Release konnte nicht erstellt werden (HTTP ${http_code_create})." >&2 + cat "${tmp_json}" >&2 + exit 1 + fi + RELEASE_ID="$(python3 - "${tmp_json}" <<'PY' +import json,sys +print(json.load(open(sys.argv[1], encoding="utf-8"))["id"]) +PY +)" +else + echo "Release-Abfrage fehlgeschlagen (HTTP ${http_code})." >&2 + cat "${tmp_json}" >&2 + exit 1 +fi + +assets_code="$(curl -sS -H "${auth_header}" -o "${tmp_json}" -w "%{http_code}" "${API_BASE}/releases/${RELEASE_ID}/assets")" +if [[ "${assets_code}" == "200" ]]; then + EXISTING_ASSET_ID="$(python3 - "${tmp_json}" "${ASSET_NAME}" <<'PY' +import json,sys +assets=json.load(open(sys.argv[1], encoding="utf-8")) +name=sys.argv[2] +for a in assets: + if a.get("name")==name: + print(a.get("id")) + break +PY +)" + if [[ -n "${EXISTING_ASSET_ID}" ]]; then + del_code="$(curl -sS -X DELETE -H "${auth_header}" -o "${tmp_http}" -w "%{http_code}" "${API_BASE}/releases/${RELEASE_ID}/assets/${EXISTING_ASSET_ID}")" + if [[ "${del_code}" != "204" ]]; then + echo "Altes Asset konnte nicht geloescht werden (HTTP ${del_code})." >&2 + cat "${tmp_http}" >&2 + exit 1 + fi + fi +fi + +asset_name_enc="$(urlenc "${ASSET_NAME}")" +upload_code="$(curl -sS -X POST -H "${auth_header}" -F "attachment=@${ASSET_PATH}" -o "${tmp_json}" -w "%{http_code}" "${API_BASE}/releases/${RELEASE_ID}/assets?name=${asset_name_enc}")" +if [[ "${upload_code}" != "201" ]]; then + echo "Asset-Upload fehlgeschlagen (HTTP ${upload_code})." >&2 + cat "${tmp_json}" >&2 + exit 1 +fi + +echo "Release-Asset hochgeladen:" +echo " Repo: ${OWNER}/${REPO}" +echo " Tag: ${TAG}" +echo " Asset: ${ASSET_NAME}" +echo " URL: ${BASE_URL}/${OWNER}/${REPO}/releases/tag/${TAG}"