Compare commits

...

8 Commits

24 changed files with 1483 additions and 329 deletions

3
.gitignore vendored
View File

@@ -17,3 +17,6 @@
__pycache__/
*.pyc
.coverage
# Plugin runtime caches
/addon/plugins/*_cache.json

View File

@@ -14,6 +14,7 @@ ViewIT ist ein KodiAddon zum Durchsuchen und Abspielen von Inhalten der unter
- AddonOrdner bauen: `./scripts/build_install_addon.sh``dist/<addon_id>/`
- KodiZIP bauen: `./scripts/build_kodi_zip.sh``dist/<addon_id>-<version>.zip`
- AddonVersion in `addon/addon.xml`
- Reproduzierbare ZIPs: optional `SOURCE_DATE_EPOCH` setzen
## Lokales Kodi-Repository
- Repository bauen (inkl. ZIPs + `addons.xml` + `addons.xml.md5`): `./scripts/build_local_kodi_repo.sh`

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<addon id="plugin.video.viewit" name="ViewIt" version="0.1.50" provider-name="ViewIt">
<addon id="plugin.video.viewit" name="ViewIt" version="0.1.56" provider-name="ViewIt">
<requires>
<import addon="xbmc.python" version="3.0.0" />
<import addon="script.module.requests" />

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import List, Optional, Set
from typing import Any, Dict, List, Optional, Set, Tuple
class BasisPlugin(ABC):
@@ -12,6 +12,7 @@ class BasisPlugin(ABC):
name: str
version: str = "0.0.0"
prefer_source_metadata: bool = False
@abstractmethod
async def search_titles(self, query: str) -> List[str]:
@@ -29,6 +30,10 @@ class BasisPlugin(ABC):
"""Optional: Liefert den Stream-Link fuer eine konkrete Folge."""
return None
def metadata_for(self, title: str) -> Tuple[Dict[str, str], Dict[str, str], Optional[List[Any]]]:
"""Optional: Liefert Info-Labels, Art und Cast fuer einen Titel."""
return {}, {}, None
def resolve_stream_link(self, link: str) -> Optional[str]:
"""Optional: Folgt einem Stream-Link und liefert die finale URL."""
return None

View File

@@ -9,7 +9,7 @@ Zum Verwenden:
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, List, Optional, TypeAlias
from typing import TYPE_CHECKING, Any, List, Optional
try: # pragma: no cover - optional dependency
import requests
@@ -34,8 +34,8 @@ if TYPE_CHECKING: # pragma: no cover
from requests import Session as RequestsSession
from bs4 import BeautifulSoup as BeautifulSoupT # type: ignore[import-not-found]
else: # pragma: no cover
RequestsSession: TypeAlias = Any
BeautifulSoupT: TypeAlias = Any
RequestsSession = Any
BeautifulSoupT = Any
ADDON_ID = "plugin.video.viewit"

View File

@@ -13,7 +13,7 @@ import hashlib
import json
import re
import time
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, TypeAlias
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
try: # pragma: no cover - optional dependency
import requests
@@ -43,8 +43,8 @@ if TYPE_CHECKING: # pragma: no cover
from requests import Session as RequestsSession
from bs4 import BeautifulSoup as BeautifulSoupT # type: ignore[import-not-found]
else: # pragma: no cover
RequestsSession: TypeAlias = Any
BeautifulSoupT: TypeAlias = Any
RequestsSession = Any
BeautifulSoupT = Any
SETTING_BASE_URL = "aniworld_base_url"
@@ -1213,6 +1213,18 @@ class AniworldPlugin(BasisPlugin):
_log_url(link, kind="FOUND")
return link
def episode_url_for(self, title: str, season: str, episode: str) -> str:
cache_key = (title, season)
cached = self._episode_label_cache.get(cache_key)
if cached:
info = cached.get(episode)
if info and info.url:
return info.url
episode_info = self._lookup_episode(title, season, episode)
if episode_info and episode_info.url:
return episode_info.url
return ""
def available_hosters_for(self, title: str, season: str, episode: str) -> List[str]:
if not self._requests_available:
raise RuntimeError("AniworldPlugin kann ohne requests/bs4 keine Hoster laden.")

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from dataclasses import dataclass
import re
from urllib.parse import quote
from typing import TYPE_CHECKING, Any, Dict, List, Optional, TypeAlias
from typing import TYPE_CHECKING, Any, Dict, List, Optional
try: # pragma: no cover - optional dependency
import requests
@@ -27,8 +27,8 @@ if TYPE_CHECKING: # pragma: no cover
from requests import Session as RequestsSession
from bs4 import BeautifulSoup as BeautifulSoupT # type: ignore[import-not-found]
else: # pragma: no cover
RequestsSession: TypeAlias = Any
BeautifulSoupT: TypeAlias = Any
RequestsSession = Any
BeautifulSoupT = Any
ADDON_ID = "plugin.video.viewit"

View File

@@ -43,7 +43,7 @@ SETTING_DUMP_HTML = "dump_html_einschalten"
SETTING_SHOW_URL_INFO = "show_url_info_einschalten"
SETTING_LOG_ERRORS = "log_errors_einschalten"
DEFAULT_BASE_URL = ""
DEFAULT_BASE_URL = "https://einschalten.in"
DEFAULT_INDEX_PATH = "/"
DEFAULT_NEW_TITLES_PATH = "/movies/new"
DEFAULT_SEARCH_PATH = "/search"
@@ -1079,3 +1079,7 @@ class EinschaltenPlugin(BasisPlugin):
return []
# Backwards compatible: first page only. UI uses paging via `new_titles_page`.
return self.new_titles_page(1)
# Alias für die automatische Plugin-Erkennung.
Plugin = EinschaltenPlugin

View File

@@ -11,7 +11,7 @@ from dataclasses import dataclass
import re
from urllib.parse import quote, urlencode
from urllib.parse import urljoin
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, TypeAlias
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
try: # pragma: no cover - optional dependency
import requests
@@ -33,8 +33,8 @@ if TYPE_CHECKING: # pragma: no cover
from requests import Session as RequestsSession
from bs4 import BeautifulSoup as BeautifulSoupT # type: ignore[import-not-found]
else: # pragma: no cover
RequestsSession: TypeAlias = Any
BeautifulSoupT: TypeAlias = Any
RequestsSession = Any
BeautifulSoupT = Any
ADDON_ID = "plugin.video.viewit"
@@ -820,11 +820,23 @@ class FilmpalastPlugin(BasisPlugin):
def available_hosters_for(self, title: str, season: str, episode: str) -> List[str]:
detail_url = self._detail_url_for_selection(title, season, episode)
hosters = self._hosters_for_detail_url(detail_url)
return list(hosters.keys())
return self.available_hosters_for_url(detail_url)
def stream_link_for(self, title: str, season: str, episode: str) -> Optional[str]:
detail_url = self._detail_url_for_selection(title, season, episode)
return self.stream_link_for_url(detail_url)
def episode_url_for(self, title: str, season: str, episode: str) -> str:
detail_url = self._detail_url_for_selection(title, season, episode)
return (detail_url or "").strip()
def available_hosters_for_url(self, episode_url: str) -> List[str]:
detail_url = (episode_url or "").strip()
hosters = self._hosters_for_detail_url(detail_url)
return list(hosters.keys())
def stream_link_for_url(self, episode_url: str) -> Optional[str]:
detail_url = (episode_url or "").strip()
if not detail_url:
return None
hosters = self._hosters_for_detail_url(detail_url)
@@ -922,3 +934,7 @@ class FilmpalastPlugin(BasisPlugin):
_log_url_event(redirected, kind="FINAL")
return redirected
return None
# Alias für die automatische Plugin-Erkennung.
Plugin = FilmpalastPlugin

View File

@@ -17,7 +17,8 @@ import os
import re
import time
import unicodedata
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, TypeAlias
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
from urllib.parse import quote
try: # pragma: no cover - optional dependency
import requests
@@ -49,14 +50,15 @@ if TYPE_CHECKING: # pragma: no cover
from requests import Session as RequestsSession
from bs4 import BeautifulSoup as BeautifulSoupT # type: ignore[import-not-found]
else: # pragma: no cover
RequestsSession: TypeAlias = Any
BeautifulSoupT: TypeAlias = Any
RequestsSession = Any
BeautifulSoupT = Any
SETTING_BASE_URL = "serienstream_base_url"
DEFAULT_BASE_URL = "https://s.to"
DEFAULT_PREFERRED_HOSTERS = ["voe"]
DEFAULT_TIMEOUT = 20
SEARCH_TIMEOUT = 8
ADDON_ID = "plugin.video.viewit"
GLOBAL_SETTING_LOG_URLS = "debug_log_urls"
GLOBAL_SETTING_DUMP_HTML = "debug_dump_html"
@@ -75,6 +77,9 @@ HEADERS = {
SESSION_CACHE_TTL_SECONDS = 300
SESSION_CACHE_PREFIX = "viewit.serienstream"
SESSION_CACHE_MAX_TITLE_URLS = 800
CATALOG_SEARCH_TTL_SECONDS = 600
CATALOG_SEARCH_CACHE_KEY = "catalog_index"
_CATALOG_INDEX_MEMORY: tuple[float, List["SeriesResult"]] = (0.0, [])
@dataclass
@@ -111,6 +116,57 @@ class SeasonInfo:
episodes: List[EpisodeInfo]
def _extract_series_metadata(soup: BeautifulSoupT) -> Tuple[Dict[str, str], Dict[str, str]]:
info: Dict[str, str] = {}
art: Dict[str, str] = {}
if not soup:
return info, art
title_tag = soup.select_one("h1")
title = (title_tag.get_text(" ", strip=True) if title_tag else "").strip()
if title:
info["title"] = title
description = ""
desc_tag = soup.select_one(".series-description .description-text")
if desc_tag:
description = (desc_tag.get_text(" ", strip=True) or "").strip()
if not description:
meta_desc = soup.select_one("meta[property='og:description'], meta[name='description']")
if meta_desc:
description = (meta_desc.get("content") or "").strip()
if description:
info["plot"] = description
poster = ""
poster_tag = soup.select_one(
".show-cover-mobile img[data-src], .show-cover-mobile img[src], .col-3 img[data-src], .col-3 img[src]"
)
if poster_tag:
poster = (poster_tag.get("data-src") or poster_tag.get("src") or "").strip()
if not poster:
for candidate in soup.select("img[data-src], img[src]"):
url = (candidate.get("data-src") or candidate.get("src") or "").strip()
if "/media/images/channel/" in url:
poster = url
break
if poster:
poster = _absolute_url(poster)
art["poster"] = poster
art["thumb"] = poster
fanart = ""
fanart_tag = soup.select_one("meta[property='og:image']")
if fanart_tag:
fanart = (fanart_tag.get("content") or "").strip()
if fanart:
fanart = _absolute_url(fanart)
art["fanart"] = fanart
art["landscape"] = fanart
return info, art
def _get_base_url() -> str:
base = get_setting_string(ADDON_ID, SETTING_BASE_URL, default=DEFAULT_BASE_URL).strip()
if not base:
@@ -400,20 +456,222 @@ def _extract_genre_names_from_html(body: str) -> List[str]:
return names
def _strip_tags(value: str) -> str:
return re.sub(r"<[^>]+>", " ", value or "")
def _search_series_api(query: str) -> List[SeriesResult]:
query = (query or "").strip()
if not query:
return []
_ensure_requests()
sess = get_requests_session("serienstream", headers=HEADERS)
terms = [query]
if " " in query:
# Fallback: einzelne Tokens liefern in der API oft bessere Treffer.
terms.extend([token for token in query.split() if token])
seen_urls: set[str] = set()
for term in terms:
try:
response = sess.get(
f"{_get_base_url()}/api/search/suggest",
params={"term": term},
headers=HEADERS,
timeout=SEARCH_TIMEOUT,
)
response.raise_for_status()
except Exception:
continue
try:
payload = response.json()
except Exception:
continue
shows = payload.get("shows") if isinstance(payload, dict) else None
if not isinstance(shows, list):
continue
results: List[SeriesResult] = []
for item in shows:
if not isinstance(item, dict):
continue
title = (item.get("name") or "").strip()
href = (item.get("url") or "").strip()
if not title or not href:
continue
url_abs = _absolute_url(href)
if not url_abs or url_abs in seen_urls:
continue
if "/staffel-" in url_abs or "/episode-" in url_abs:
continue
seen_urls.add(url_abs)
results.append(SeriesResult(title=title, description="", url=url_abs))
if not results:
continue
filtered = [entry for entry in results if _matches_query(query, title=entry.title)]
if filtered:
return filtered
# Falls nur Token-Suche möglich war, zumindest die Ergebnisse liefern.
if term != query:
return results
return []
def _search_series_server(query: str) -> List[SeriesResult]:
if not query:
return []
api_results = _search_series_api(query)
if api_results:
return api_results
base = _get_base_url()
search_url = f"{base}/search?q={quote(query)}"
alt_url = f"{base}/suche?q={quote(query)}"
for url in (search_url, alt_url):
try:
body = _get_html_simple(url)
except Exception:
continue
if not body:
continue
soup = BeautifulSoup(body, "html.parser")
root = soup.select_one(".search-results-list")
if root is None:
continue
seen_urls: set[str] = set()
results: List[SeriesResult] = []
for card in root.select(".cover-card"):
anchor = card.select_one("a[href*='/serie/']")
if not anchor:
continue
href = (anchor.get("href") or "").strip()
url_abs = _absolute_url(href)
if not url_abs or url_abs in seen_urls:
continue
if "/staffel-" in url_abs or "/episode-" in url_abs:
continue
title_tag = card.select_one(".show-title") or card.select_one("h3") or card.select_one("h4")
title = (title_tag.get_text(" ", strip=True) if title_tag else anchor.get_text(" ", strip=True)).strip()
if not title:
continue
seen_urls.add(url_abs)
results.append(SeriesResult(title=title, description="", url=url_abs))
if results:
return results
return []
def _extract_catalog_index_from_html(body: str) -> 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 match in item_re.finditer(body):
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 ""
if title and url:
items.append(SeriesResult(title=title, description=description, url=url))
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])
_session_cache_set(CATALOG_SEARCH_CACHE_KEY, payload, ttl_seconds=CATALOG_SEARCH_TTL_SECONDS)
def search_series(query: str) -> List[SeriesResult]:
"""Sucht Serien im (/serien)-Katalog (Genre-liste) nach Titel/Alt-Titel."""
"""Sucht Serien im (/serien)-Katalog nach Titel. Nutzt Cache + Ein-Pass-Filter."""
_ensure_requests()
if not _normalize_search_text(query):
return []
# Direkter Abruf wie in fetch_serien.py.
server_results = _search_series_server(query)
if server_results:
return [entry for entry in server_results if entry.title and _matches_query(query, title=entry.title)]
cached = _load_catalog_index_from_cache()
if cached is not None:
return [entry for entry in cached if entry.title and _matches_query(query, title=entry.title)]
catalog_url = f"{_get_base_url()}/serien?by=genre"
soup = _get_soup_simple(catalog_url)
results: List[SeriesResult] = []
for series in parse_series_catalog(soup).values():
for entry in series:
if entry.title and _matches_query(query, title=entry.title):
results.append(entry)
return results
body = _get_html_simple(catalog_url)
items = _extract_catalog_index_from_html(body)
if not items:
soup = BeautifulSoup(body, "html.parser")
items = _catalog_index_from_soup(soup)
if items:
_store_catalog_index_in_cache(items)
return [entry for entry in items if entry.title and _matches_query(query, title=entry.title)]
def parse_series_catalog(soup: BeautifulSoupT) -> Dict[str, List[SeriesResult]]:
@@ -805,6 +1063,7 @@ class SerienstreamPlugin(BasisPlugin):
self._hoster_cache: Dict[Tuple[str, str, str], List[str]] = {}
self._latest_cache: Dict[int, List[LatestEpisode]] = {}
self._latest_hoster_cache: Dict[str, List[str]] = {}
self._series_metadata_cache: Dict[str, Tuple[Dict[str, str], Dict[str, str]]] = {}
self.is_available = True
self.unavailable_reason: Optional[str] = None
if not self._requests_available: # pragma: no cover - optional dependency
@@ -851,12 +1110,30 @@ class SerienstreamPlugin(BasisPlugin):
cache_key = title.casefold()
if self._title_url_cache.get(cache_key) != url:
self._title_url_cache[cache_key] = url
self._save_title_url_cache()
self._save_title_url_cache()
if url:
return
current = self._series_results.get(title)
if current is None:
self._series_results[title] = SeriesResult(title=title, description=description, url="")
@staticmethod
def _metadata_cache_key(title: str) -> str:
return (title or "").strip().casefold()
def _series_for_title(self, title: str) -> Optional[SeriesResult]:
direct = self._series_results.get(title)
if direct and direct.url:
return direct
lookup_key = (title or "").strip().casefold()
for item in self._series_results.values():
if item.title.casefold().strip() == lookup_key and item.url:
return item
cached_url = self._title_url_cache.get(lookup_key, "")
if cached_url:
return SeriesResult(title=title, description="", url=cached_url)
return None
@staticmethod
def _season_links_cache_name(series_url: str) -> str:
digest = hashlib.sha1((series_url or "").encode("utf-8")).hexdigest()[:20]
@@ -1274,7 +1551,28 @@ class SerienstreamPlugin(BasisPlugin):
self._season_links_cache[title] = list(session_links)
return list(session_links)
try:
seasons = scrape_series_detail(series.url, load_episodes=False)
series_soup = _get_soup(series.url, session=get_requests_session("serienstream", headers=HEADERS))
info_labels, art = _extract_series_metadata(series_soup)
if series.description and "plot" not in info_labels:
info_labels["plot"] = series.description
cache_key = self._metadata_cache_key(title)
if info_labels or art:
self._series_metadata_cache[cache_key] = (info_labels, art)
base_series_url = _series_root_url(_extract_canonical_url(series_soup, series.url))
season_links = _extract_season_links(series_soup)
season_count = _extract_number_of_seasons(series_soup)
if season_count and (not season_links or len(season_links) < season_count):
existing = {number for number, _ in season_links}
for number in range(1, season_count + 1):
if number in existing:
continue
season_url = f"{base_series_url}/staffel-{number}"
_log_parsed_url(season_url)
season_links.append((number, season_url))
season_links.sort(key=lambda item: item[0])
seasons = [SeasonInfo(number=number, url=url, episodes=[]) for number, url in season_links]
seasons.sort(key=lambda s: s.number)
except Exception as exc: # pragma: no cover - defensive logging
raise RuntimeError(f"Serienstream-Staffeln konnten nicht geladen werden: {exc}") from exc
self._season_links_cache[title] = list(seasons)
@@ -1288,6 +1586,41 @@ class SerienstreamPlugin(BasisPlugin):
return
self._remember_series_result(title, series_url)
def metadata_for(self, title: str) -> Tuple[Dict[str, str], Dict[str, str], Optional[List[Any]]]:
title = (title or "").strip()
if not title or not self._requests_available:
return {}, {}, None
cache_key = self._metadata_cache_key(title)
cached = self._series_metadata_cache.get(cache_key)
if cached is not None:
info, art = cached
return dict(info), dict(art), None
series = self._series_for_title(title)
if series is None or not series.url:
info = {"title": title}
self._series_metadata_cache[cache_key] = (dict(info), {})
return info, {}, None
info: Dict[str, str] = {"title": title}
art: Dict[str, str] = {}
if series.description:
info["plot"] = series.description
try:
soup = _get_soup(series.url, session=get_requests_session("serienstream", headers=HEADERS))
parsed_info, parsed_art = _extract_series_metadata(soup)
if parsed_info:
info.update(parsed_info)
if parsed_art:
art.update(parsed_art)
except Exception:
pass
self._series_metadata_cache[cache_key] = (dict(info), dict(art))
return info, art, None
def series_url_for_title(self, title: str) -> str:
title = (title or "").strip()
if not title:
@@ -1443,6 +1776,18 @@ class SerienstreamPlugin(BasisPlugin):
except Exception as exc: # pragma: no cover - defensive logging
raise RuntimeError(f"Stream-Link konnte nicht geladen werden: {exc}") from exc
def episode_url_for(self, title: str, season: str, episode: str) -> str:
cache_key = (title, season)
cached = self._episode_label_cache.get(cache_key)
if cached:
info = cached.get(episode)
if info and info.url:
return info.url
episode_info = self._lookup_episode(title, season, episode)
if episode_info and episode_info.url:
return episode_info.url
return ""
def available_hosters_for(self, title: str, season: str, episode: str) -> List[str]:
if not self._requests_available:
raise RuntimeError("SerienstreamPlugin kann ohne requests/bs4 keine Hoster laden.")

View File

@@ -19,7 +19,7 @@ import hashlib
import os
import re
import json
from typing import TYPE_CHECKING, Any, Dict, List, Optional, TypeAlias
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from urllib.parse import urlencode, urljoin
try: # pragma: no cover - optional dependency
@@ -51,13 +51,13 @@ if TYPE_CHECKING: # pragma: no cover
from requests import Session as RequestsSession
from bs4 import BeautifulSoup as BeautifulSoupT # type: ignore[import-not-found]
else: # pragma: no cover
RequestsSession: TypeAlias = Any
BeautifulSoupT: TypeAlias = Any
RequestsSession = Any
BeautifulSoupT = Any
ADDON_ID = "plugin.video.viewit"
SETTING_BASE_URL = "topstream_base_url"
DEFAULT_BASE_URL = "https://www.meineseite"
DEFAULT_BASE_URL = "https://topstreamfilm.live"
GLOBAL_SETTING_LOG_URLS = "debug_log_urls"
GLOBAL_SETTING_DUMP_HTML = "debug_dump_html"
GLOBAL_SETTING_SHOW_URL_INFO = "debug_show_url_info"

View File

@@ -31,22 +31,28 @@
</category>
<category label="TopStream">
<setting id="topstream_base_url" type="text" label="Domain (BASE_URL)" default="https://topstreamfilm.live" />
<setting id="topstreamfilm_metadata_source" type="enum" label="Metadatenquelle" default="0" values="Auto|Quelle|TMDB|Mix" />
<setting id="topstream_genre_max_pages" type="number" label="Genres: max. Seiten laden (Pagination)" default="20" />
</category>
<category label="SerienStream">
<setting id="serienstream_base_url" type="text" label="Domain (BASE_URL)" default="https://s.to" />
<setting id="serienstream_metadata_source" type="enum" label="Metadatenquelle" default="0" values="Auto|Quelle|TMDB|Mix" />
</category>
<category label="AniWorld">
<setting id="aniworld_base_url" type="text" label="Domain (BASE_URL)" default="https://aniworld.to" />
<setting id="aniworld_metadata_source" type="enum" label="Metadatenquelle" default="0" values="Auto|Quelle|TMDB|Mix" />
</category>
<category label="Einschalten">
<setting id="einschalten_base_url" type="text" label="Domain (BASE_URL)" default="https://einschalten.in" />
<setting id="einschalten_metadata_source" type="enum" label="Metadatenquelle" default="0" values="Auto|Quelle|TMDB|Mix" />
</category>
<category label="Filmpalast">
<setting id="filmpalast_base_url" type="text" label="Domain (BASE_URL)" default="https://filmpalast.to" />
<setting id="filmpalast_metadata_source" type="enum" label="Metadatenquelle" default="0" values="Auto|Quelle|TMDB|Mix" />
</category>
<category label="Doku-Streams">
<setting id="doku_streams_base_url" type="text" label="Domain (BASE_URL)" default="https://doku-streams.com" />
<setting id="doku_streams_metadata_source" type="enum" label="Metadatenquelle" default="0" values="Auto|Quelle|TMDB|Mix" />
</category>
<category label="TMDB">
<setting id="tmdb_enabled" type="bool" label="TMDB aktivieren" default="true" />

View File

@@ -10,7 +10,7 @@ Dieses Dokument beschreibt den Einstiegspunkt des Addons und die zentrale Steuer
- startet die Wiedergabe und verwaltet Playstate/Resume.
## Ablauf (high level)
1. **PluginDiscovery**: Lädt alle `addon/plugins/*.py` (ohne `_`Prefix) und instanziiert Klassen, die von `BasisPlugin` erben.
1. **PluginDiscovery**: Lädt alle `addon/plugins/*.py` (ohne `_`Prefix). Bevorzugt `Plugin = <Klasse>`, sonst werden `BasisPlugin`Subklassen deterministisch instanziiert.
2. **Navigation**: Baut KodiListen (Serien/Staffeln/Episoden) auf Basis der PluginAntworten.
3. **Playback**: Holt StreamLinks aus dem Plugin und startet die Wiedergabe.
4. **Playstate**: Speichert ResumeDaten lokal (`playstate.json`) und setzt `playcount`/ResumeInfos.
@@ -35,6 +35,7 @@ Die genaue Aktion wird aus den QueryParametern gelesen und an das entsprechen
- **PluginLoader**: findet & instanziiert Plugins.
- **UIHelper**: setzt ContentType, baut Verzeichnisseinträge.
- **PlaystateHelper**: `_load_playstate`, `_save_playstate`, `_apply_playstate_to_info`.
- **MetadataMerge**: PluginMetadaten können TMDB übersteuern, TMDB dient als Fallback.
## Fehlerbehandlung
- PluginImportfehler werden isoliert behandelt, damit das Addon nicht komplett ausfällt.

View File

@@ -6,6 +6,7 @@ Diese Doku beschreibt, wie Plugins im ViewITAddon aufgebaut sind und wie neue
- Jedes Plugin ist eine einzelne Datei unter `addon/plugins/`.
- Dateinamen **ohne** `_`Prefix werden automatisch geladen.
- Jede Datei enthält eine Klasse, die von `BasisPlugin` erbt.
- Optional: `Plugin = <Klasse>` als expliziter Einstiegspunkt (bevorzugt vom Loader).
## PflichtMethoden (BasisPlugin)
Jedes Plugin muss diese Methoden implementieren:
@@ -22,6 +23,7 @@ Wesentliche Rückgaben an die Hauptlogik:
- `episodes_for(...)` → Liste von Episoden-Labels
- `stream_link_for(...)` → Hoster-/Player-Link (nicht zwingend finale Media-URL)
- `resolve_stream_link(...)` → finale/spielbare URL nach Redirect/Resolver
- `metadata_for(...)` → Info-Labels/Art (Plot/Poster) aus der Quelle
- Optional `available_hosters_for(...)` → auswählbare Hoster-Namen im Dialog
- Optional `series_url_for_title(...)` → stabile Detail-URL pro Titel für Folgeaufrufe
- Optional `remember_series_url(...)` → Übernahme einer bereits bekannten Detail-URL
@@ -35,6 +37,9 @@ Standard für Film-Provider (ohne echte Staffeln):
- `popular_series``popular_series()`
- `genres``genres()` + `titles_for_genre(genre)`
- `latest_episodes``latest_episodes(page=1)`
- `new_titles``new_titles_page(page=1)`
- `alpha``alpha_index()` + `titles_for_alpha_page(letter, page)`
- `series_catalog``series_catalog_page(page=1)`
## Empfohlene Struktur
- Konstanten für URLs/Endpoints (BASE_URL, Pfade, Templates)
@@ -60,6 +65,7 @@ Standard: `*_base_url` (Domain / BASE_URL)
- `einschalten_base_url`
- `topstream_base_url`
- `filmpalast_base_url`
- `doku_streams_base_url`
## Playback
- `stream_link_for(...)` implementieren (liefert bevorzugten Hoster-Link).
@@ -75,7 +81,8 @@ Standard: `*_base_url` (Domain / BASE_URL)
2. **Navigation**: `series_url_for_title`/`remember_series_url` unterstützen, damit URLs zwischen Aufrufen stabil bleiben.
3. **Auswahl Hoster**: Hoster-Namen aus der Detailseite extrahieren und anbieten.
4. **Playback**: Hoster-Link liefern, danach konsistent über `resolve_stream_link` finalisieren.
5. **Fallbacks**: bei Layout-Unterschieden defensiv parsen und Logging aktivierbar halten.
5. **Metadaten**: `metadata_for` nutzen, Plot/Poster aus der Quelle zurückgeben.
6. **Fallbacks**: bei Layout-Unterschieden defensiv parsen und Logging aktivierbar halten.
## Debugging
Global gesteuert über Settings:
@@ -94,6 +101,8 @@ Plugins sollten die Helper aus `addon/plugin_helpers.py` nutzen:
## Build & Test
- ZIP bauen: `./scripts/build_kodi_zip.sh`
- AddonOrdner: `./scripts/build_install_addon.sh`
- PluginManifest aktualisieren: `python3 scripts/generate_plugin_manifest.py`
- Live-Snapshot-Checks: `python3 qa/run_plugin_snapshots.py` (aktualisieren mit `--update`)
## BeispielCheckliste
- [ ] `name` korrekt gesetzt

104
docs/PLUGIN_MANIFEST.json Normal file
View File

@@ -0,0 +1,104 @@
{
"schema_version": 1,
"plugins": [
{
"file": "addon/plugins/aniworld_plugin.py",
"module": "aniworld_plugin",
"name": "Aniworld",
"class": "AniworldPlugin",
"version": "1.0.0",
"capabilities": [
"genres",
"latest_episodes",
"popular_series"
],
"prefer_source_metadata": false,
"base_url_setting": "aniworld_base_url",
"available": true,
"unavailable_reason": null,
"error": null
},
{
"file": "addon/plugins/dokustreams_plugin.py",
"module": "dokustreams_plugin",
"name": "Doku-Streams",
"class": "DokuStreamsPlugin",
"version": "1.0.0",
"capabilities": [
"genres",
"popular_series"
],
"prefer_source_metadata": true,
"base_url_setting": "doku_streams_base_url",
"available": true,
"unavailable_reason": null,
"error": null
},
{
"file": "addon/plugins/einschalten_plugin.py",
"module": "einschalten_plugin",
"name": "Einschalten",
"class": "EinschaltenPlugin",
"version": "1.0.0",
"capabilities": [
"genres",
"new_titles"
],
"prefer_source_metadata": false,
"base_url_setting": "einschalten_base_url",
"available": true,
"unavailable_reason": null,
"error": null
},
{
"file": "addon/plugins/filmpalast_plugin.py",
"module": "filmpalast_plugin",
"name": "Filmpalast",
"class": "FilmpalastPlugin",
"version": "1.0.0",
"capabilities": [
"alpha",
"genres",
"series_catalog"
],
"prefer_source_metadata": false,
"base_url_setting": "filmpalast_base_url",
"available": true,
"unavailable_reason": null,
"error": null
},
{
"file": "addon/plugins/serienstream_plugin.py",
"module": "serienstream_plugin",
"name": "Serienstream",
"class": "SerienstreamPlugin",
"version": "1.0.0",
"capabilities": [
"genres",
"latest_episodes",
"popular_series"
],
"prefer_source_metadata": false,
"base_url_setting": "serienstream_base_url",
"available": true,
"unavailable_reason": null,
"error": null
},
{
"file": "addon/plugins/topstreamfilm_plugin.py",
"module": "topstreamfilm_plugin",
"name": "Topstreamfilm",
"class": "TopstreamfilmPlugin",
"version": "1.0.0",
"capabilities": [
"genres",
"popular_series"
],
"prefer_source_metadata": false,
"base_url_setting": "topstream_base_url",
"available": true,
"unavailable_reason": null,
"error": null
}
]
}

View File

@@ -9,6 +9,7 @@ ViewIt lädt Provider-Integrationen dynamisch aus `addon/plugins/*.py`. Jede Dat
Weitere Details:
- `docs/DEFAULT_ROUTER.md` (Hauptlogik in `addon/default.py`)
- `docs/PLUGIN_DEVELOPMENT.md` (Entwicklerdoku für Plugins)
- `docs/PLUGIN_MANIFEST.json` (zentraler Überblick über Plugins, Versionen, Capabilities)
### Aktuelle Plugins
@@ -17,6 +18,7 @@ Weitere Details:
- `einschalten_plugin.py` Einschalten
- `aniworld_plugin.py` Aniworld
- `filmpalast_plugin.py` Filmpalast
- `dokustreams_plugin.py` Doku-Streams
- `_template_plugin.py` Vorlage für neue Plugins
### Plugin-Discovery (Ladeprozess)
@@ -26,11 +28,16 @@ Der Loader in `addon/default.py`:
1. Sucht alle `*.py` in `addon/plugins/`
2. Überspringt Dateien, die mit `_` beginnen
3. Lädt Module dynamisch
4. Instanziert Klassen, die von `BasisPlugin` erben
5. Ignoriert Plugins mit `is_available = False`
4. Nutzt `Plugin = <Klasse>` als bevorzugten Einstiegspunkt (falls vorhanden)
5. Fallback: instanziert Klassen, die von `BasisPlugin` erben (deterministisch sortiert)
6. Ignoriert Plugins mit `is_available = False`
Damit bleiben fehlerhafte Plugins isoliert und blockieren nicht das gesamte Add-on.
### Plugin-Manifest (Audit & Repro)
`docs/PLUGIN_MANIFEST.json` listet alle Plugins mit Version, Capabilities und Basis-Settings.
Erzeugung: `python3 scripts/generate_plugin_manifest.py`
### BasisPlugin verpflichtende Methoden
Definiert in `addon/plugin_interface.py`:
@@ -38,19 +45,29 @@ Definiert in `addon/plugin_interface.py`:
- `async search_titles(query: str) -> list[str]`
- `seasons_for(title: str) -> list[str]`
- `episodes_for(title: str, season: str) -> list[str]`
- optional `metadata_for(title: str) -> (info_labels, art, cast)`
### Optionale Features (Capabilities)
Plugins können zusätzliche Features anbieten:
- `capabilities() -> set[str]`
- `popular_series`: liefert beliebte Serien
- `genres`: Genre-Liste verfügbar
- `latest_episodes`: neue Episoden verfügbar
- `popular_series`: liefert beliebte Serien
- `genres`: Genre-Liste verfügbar
- `latest_episodes`: neue Episoden verfügbar
- `new_titles`: neue Titel verfügbar
- `alpha`: A-Z Index verfügbar
- `series_catalog`: Serienkatalog verfügbar
- `popular_series() -> list[str]`
- `genres() -> list[str]`
- `titles_for_genre(genre: str) -> list[str]`
- `latest_episodes(page: int = 1) -> list[LatestEpisode]` (wenn angeboten)
- `new_titles_page(page: int = 1) -> list[str]` (wenn angeboten)
- `alpha_index() -> list[str]` (wenn angeboten)
- `series_catalog_page(page: int = 1) -> list[str]` (wenn angeboten)
Metadaten:
- `prefer_source_metadata = True` bedeutet: Plugin-Metadaten gehen vor TMDB, TMDB dient nur als Fallback.
ViewIt zeigt im UI nur die Features an, die ein Plugin tatsächlich liefert.
@@ -62,6 +79,7 @@ Eine Integration sollte typischerweise bieten:
- `search_titles()` mit Provider-Suche
- `seasons_for()` und `episodes_for()` mit HTML-Parsing
- `stream_link_for()` optional für direkte Playback-Links
- `metadata_for()` optional für Plot/Poster aus der Quelle
- Optional: `available_hosters_for()` oder Provider-spezifische Helfer
Als Startpunkt dient `addon/plugins/_template_plugin.py`.
@@ -79,8 +97,9 @@ Als Startpunkt dient `addon/plugins/_template_plugin.py`.
- Keine Netzwerkzugriffe im Import-Top-Level
- Netzwerkzugriffe nur in Methoden (z.B. `search_titles`)
- Fehler sauber abfangen und verständliche Fehlermeldungen liefern
- Kein globaler Zustand, der across instances überrascht
- Kein globaler Zustand, der über Instanzen hinweg überrascht
- Provider-spezifische Parser in Helper-Funktionen kapseln
- Reproduzierbare Reihenfolge: `Plugin`-Alias nutzen oder Klassenname eindeutig halten
### Debugging & Logs

73
qa/plugin_snapshots.json Normal file
View File

@@ -0,0 +1,73 @@
{
"snapshots": {
"Serienstream::search_titles::trek": [
"Star Trek: Lower Decks",
"Star Trek: Prodigy",
"Star Trek: The Animated Series",
"Inside Star Trek",
"Raumschiff Enterprise - Star Trek: The Original Series",
"Star Trek: Deep Space Nine",
"Star Trek: Discovery",
"Star Trek: Enterprise",
"Star Trek: Picard",
"Star Trek: Raumschiff Voyager",
"Star Trek: Short Treks",
"Star Trek: Starfleet Academy",
"Star Trek: Strange New Worlds",
"Star Trek: The Next Generation"
],
"Aniworld::search_titles::naruto": [
"Naruto",
"Naruto Shippuden",
"Boruto: Naruto Next Generations",
"Naruto Spin-Off: Rock Lee &amp; His Ninja Pals"
],
"Topstreamfilm::search_titles::matrix": [
"Darkdrive Verschollen in der Matrix",
"Matrix Reloaded",
"Armitage III: Poly Matrix",
"Matrix Resurrections",
"Matrix",
"Matrix Revolutions",
"Matrix Fighters"
],
"Einschalten::new_titles_page::1": [
"Miracle: Das Eishockeywunder von 1980",
"No Escape - Grizzly Night",
"Kidnapped: Der Fall Elizabeth Smart",
"The Internship",
"The Rip",
"Die Toten vom Bodensee Schicksalsrad",
"People We Meet on Vacation",
"Anaconda",
"Even If This Love Disappears Tonight",
"Die Stunde der Mutigen",
"10DANCE",
"SpongeBob Schwammkopf: Piraten Ahoi!",
"Ella McCay",
"Merv",
"Elmo and Mark Rober's Merry Giftmas",
"Als mein Vater Weihnachten rettete 2",
"Die Fraggles: Der erste Schnee",
"Gregs Tagebuch 3: Jetzt reicht's!",
"Not Without Hope",
"Five Nights at Freddy's 2"
],
"Filmpalast::search_titles::trek": [
"Star Trek",
"Star Trek - Der Film",
"Star Trek 2 - Der Zorn des Khan",
"Star Trek 9 Der Aufstand",
"Star Trek: Nemesis",
"Star Trek: Section 31",
"Star Trek: Starfleet Academy",
"Star Trek: Strange New Worlds"
],
"Doku-Streams::search_titles::japan": [
"Deutsche im Knast - Japan und die Disziplin",
"Die Meerfrauen von Japan",
"Japan - Land der Moderne und Tradition",
"Japan im Zweiten Weltkrieg - Der Fall des Kaiserreichs"
]
}
}

153
qa/run_plugin_snapshots.py Executable file
View File

@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""Run live snapshot checks for plugins.
Use --update to refresh stored snapshots.
"""
from __future__ import annotations
import argparse
import asyncio
import importlib.util
import inspect
import json
import sys
from pathlib import Path
from typing import Any
ROOT_DIR = Path(__file__).resolve().parents[1]
PLUGIN_DIR = ROOT_DIR / "addon" / "plugins"
SNAPSHOT_PATH = ROOT_DIR / "qa" / "plugin_snapshots.json"
sys.path.insert(0, str(ROOT_DIR / "addon"))
try:
from plugin_interface import BasisPlugin # type: ignore
except Exception as exc: # pragma: no cover
raise SystemExit(f"Failed to import BasisPlugin: {exc}")
CONFIG = [
{"plugin": "Serienstream", "method": "search_titles", "args": ["trek"], "max_items": 20},
{"plugin": "Aniworld", "method": "search_titles", "args": ["naruto"], "max_items": 20},
{"plugin": "Topstreamfilm", "method": "search_titles", "args": ["matrix"], "max_items": 20},
{"plugin": "Einschalten", "method": "new_titles_page", "args": [1], "max_items": 20},
{"plugin": "Filmpalast", "method": "search_titles", "args": ["trek"], "max_items": 20},
{"plugin": "Doku-Streams", "method": "search_titles", "args": ["japan"], "max_items": 20},
]
def _import_module(path: Path):
spec = importlib.util.spec_from_file_location(path.stem, path)
if spec is None or spec.loader is None:
raise ImportError(f"Missing spec for {path}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def _discover_plugins() -> dict[str, BasisPlugin]:
plugins: dict[str, BasisPlugin] = {}
for file_path in sorted(PLUGIN_DIR.glob("*.py")):
if file_path.name.startswith("_"):
continue
module = _import_module(file_path)
preferred = getattr(module, "Plugin", None)
if inspect.isclass(preferred) and issubclass(preferred, BasisPlugin) and preferred is not BasisPlugin:
classes = [preferred]
else:
classes = [
obj
for obj in module.__dict__.values()
if inspect.isclass(obj) and issubclass(obj, BasisPlugin) and obj is not BasisPlugin
]
classes.sort(key=lambda cls: cls.__name__.casefold())
for cls in classes:
instance = cls()
name = str(getattr(instance, "name", "") or "").strip()
if name and name not in plugins:
plugins[name] = instance
return plugins
def _normalize_titles(value: Any, max_items: int) -> list[str]:
if not value:
return []
titles = [str(item).strip() for item in list(value) if item and str(item).strip()]
seen = set()
normalized: list[str] = []
for title in titles:
key = title.casefold()
if key in seen:
continue
seen.add(key)
normalized.append(title)
if len(normalized) >= max_items:
break
return normalized
def _snapshot_key(entry: dict[str, Any]) -> str:
args = entry.get("args", [])
return f"{entry['plugin']}::{entry['method']}::{','.join(str(a) for a in args)}"
def _call_method(plugin: BasisPlugin, method_name: str, args: list[Any]):
method = getattr(plugin, method_name, None)
if not callable(method):
raise RuntimeError(f"Method missing: {method_name}")
result = method(*args)
if asyncio.iscoroutine(result):
return asyncio.run(result)
return result
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--update", action="store_true")
args = parser.parse_args()
snapshots: dict[str, Any] = {}
if SNAPSHOT_PATH.exists():
snapshots = json.loads(SNAPSHOT_PATH.read_text(encoding="utf-8"))
data = snapshots.get("snapshots", {}) if isinstance(snapshots, dict) else {}
if args.update:
data = {}
plugins = _discover_plugins()
errors = []
for entry in CONFIG:
plugin_name = entry["plugin"]
plugin = plugins.get(plugin_name)
if plugin is None:
errors.append(f"Plugin missing: {plugin_name}")
continue
key = _snapshot_key(entry)
try:
result = _call_method(plugin, entry["method"], entry.get("args", []))
normalized = _normalize_titles(result, entry.get("max_items", 20))
except Exception as exc:
errors.append(f"Snapshot error: {key} ({exc})")
if args.update:
data[key] = {"error": str(exc)}
continue
if args.update:
data[key] = normalized
else:
expected = data.get(key)
if expected != normalized:
errors.append(f"Snapshot mismatch: {key}\nExpected: {expected}\nActual: {normalized}")
if args.update:
SNAPSHOT_PATH.parent.mkdir(parents=True, exist_ok=True)
SNAPSHOT_PATH.write_text(json.dumps({"snapshots": data}, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
if errors:
for err in errors:
print(err)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -37,6 +37,6 @@ ZIP_PATH="${INSTALL_DIR}/${ZIP_NAME}"
ADDON_DIR="$("${ROOT_DIR}/scripts/build_install_addon.sh" >/dev/null; echo "${INSTALL_DIR}/${ADDON_ID}")"
rm -f "${ZIP_PATH}"
(cd "${INSTALL_DIR}" && zip -r "${ZIP_NAME}" "$(basename "${ADDON_DIR}")" >/dev/null)
python3 "${ROOT_DIR}/scripts/zip_deterministic.py" "${ZIP_PATH}" "${ADDON_DIR}" >/dev/null
echo "${ZIP_PATH}"

View File

@@ -73,7 +73,7 @@ PY
REPO_ZIP_NAME="${REPO_ADDON_ID}-${REPO_ADDON_VERSION}.zip"
REPO_ZIP_PATH="${REPO_DIR}/${REPO_ZIP_NAME}"
rm -f "${REPO_ZIP_PATH}"
(cd "${TMP_DIR}" && zip -r "${REPO_ZIP_PATH}" "${REPO_ADDON_ID}" >/dev/null)
python3 "${ROOT_DIR}/scripts/zip_deterministic.py" "${REPO_ZIP_PATH}" "${TMP_REPO_ADDON_DIR}" >/dev/null
python3 - "${PLUGIN_ADDON_XML}" "${TMP_REPO_ADDON_DIR}/addon.xml" "${REPO_DIR}/addons.xml" <<'PY'
import sys

View File

@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Generate a JSON manifest for addon plugins."""
from __future__ import annotations
import importlib.util
import inspect
import json
import sys
from pathlib import Path
ROOT_DIR = Path(__file__).resolve().parents[1]
PLUGIN_DIR = ROOT_DIR / "addon" / "plugins"
OUTPUT_PATH = ROOT_DIR / "docs" / "PLUGIN_MANIFEST.json"
sys.path.insert(0, str(ROOT_DIR / "addon"))
try:
from plugin_interface import BasisPlugin # type: ignore
except Exception as exc: # pragma: no cover
raise SystemExit(f"Failed to import BasisPlugin: {exc}")
def _import_module(path: Path):
spec = importlib.util.spec_from_file_location(path.stem, path)
if spec is None or spec.loader is None:
raise ImportError(f"Missing spec for {path}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def _collect_plugins():
plugins = []
for file_path in sorted(PLUGIN_DIR.glob("*.py")):
if file_path.name.startswith("_"):
continue
entry = {
"file": str(file_path.relative_to(ROOT_DIR)),
"module": file_path.stem,
"name": None,
"class": None,
"version": None,
"capabilities": [],
"prefer_source_metadata": False,
"base_url_setting": None,
"available": None,
"unavailable_reason": None,
"error": None,
}
try:
module = _import_module(file_path)
preferred = getattr(module, "Plugin", None)
if inspect.isclass(preferred) and issubclass(preferred, BasisPlugin) and preferred is not BasisPlugin:
classes = [preferred]
else:
classes = [
obj
for obj in module.__dict__.values()
if inspect.isclass(obj) and issubclass(obj, BasisPlugin) and obj is not BasisPlugin
]
classes.sort(key=lambda cls: cls.__name__.casefold())
if not classes:
entry["error"] = "No plugin classes found"
plugins.append(entry)
continue
cls = classes[0]
instance = cls()
entry["class"] = cls.__name__
entry["name"] = str(getattr(instance, "name", "") or "") or None
entry["version"] = str(getattr(instance, "version", "0.0.0") or "0.0.0")
entry["prefer_source_metadata"] = bool(getattr(instance, "prefer_source_metadata", False))
entry["available"] = bool(getattr(instance, "is_available", True))
entry["unavailable_reason"] = getattr(instance, "unavailable_reason", None)
try:
caps = instance.capabilities() # type: ignore[call-arg]
entry["capabilities"] = sorted([str(c) for c in caps]) if caps else []
except Exception:
entry["capabilities"] = []
entry["base_url_setting"] = getattr(module, "SETTING_BASE_URL", None)
except Exception as exc: # pragma: no cover
entry["error"] = str(exc)
plugins.append(entry)
plugins.sort(key=lambda item: (item.get("name") or item["module"]).casefold())
return plugins
def main() -> int:
if not PLUGIN_DIR.exists():
raise SystemExit("Plugin directory missing")
manifest = {
"schema_version": 1,
"plugins": _collect_plugins(),
}
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
OUTPUT_PATH.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
print(str(OUTPUT_PATH))
return 0
if __name__ == "__main__":
raise SystemExit(main())

73
scripts/zip_deterministic.py Executable file
View File

@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""Create deterministic zip archives.
Usage:
zip_deterministic.py <zip_path> <root_dir>
The archive will include the root directory itself and all files under it.
"""
from __future__ import annotations
import os
import sys
import time
import zipfile
from pathlib import Path
def _timestamp() -> tuple[int, int, int, int, int, int]:
epoch = os.environ.get("SOURCE_DATE_EPOCH")
if epoch:
try:
value = int(epoch)
return time.gmtime(value)[:6]
except Exception:
pass
return (2000, 1, 1, 0, 0, 0)
def _iter_files(root: Path):
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = sorted([d for d in dirnames if d != "__pycache__"])
for filename in sorted(filenames):
if filename.endswith(".pyc"):
continue
yield Path(dirpath) / filename
def _add_file(zf: zipfile.ZipFile, file_path: Path, arcname: str) -> None:
info = zipfile.ZipInfo(arcname, date_time=_timestamp())
info.compress_type = zipfile.ZIP_DEFLATED
info.external_attr = (0o644 & 0xFFFF) << 16
with file_path.open("rb") as handle:
data = handle.read()
zf.writestr(info, data, compress_type=zipfile.ZIP_DEFLATED)
def main() -> int:
if len(sys.argv) != 3:
print("Usage: zip_deterministic.py <zip_path> <root_dir>")
return 2
zip_path = Path(sys.argv[1]).resolve()
root = Path(sys.argv[2]).resolve()
if not root.exists() or not root.is_dir():
print(f"Missing root dir: {root}")
return 2
base = root.parent
zip_path.parent.mkdir(parents=True, exist_ok=True)
if zip_path.exists():
zip_path.unlink()
with zipfile.ZipFile(zip_path, "w") as zf:
for file_path in sorted(_iter_files(root)):
arcname = str(file_path.relative_to(base)).replace(os.sep, "/")
_add_file(zf, file_path, arcname)
print(str(zip_path))
return 0
if __name__ == "__main__":
raise SystemExit(main())