#!/usr/bin/env python3 """Package Scene Search for the Jellyfin catalog and optionally publish to Gitea. Requires only Python stdlib. Run from repository root after a Release build. """ import argparse import base64 import datetime as dt import hashlib import json import os from pathlib import Path import re import sys import urllib.error import urllib.parse import urllib.request import zipfile ROOT = Path(__file__).resolve().parents[1] PROJECT = ROOT / "jellyfin-plugin/Jellyfin.Plugin.SceneSearch/Jellyfin.Plugin.SceneSearch.csproj" DLL = ROOT / "jellyfin-plugin/Jellyfin.Plugin.SceneSearch/bin/Release/net10.0/Jellyfin.Plugin.SceneSearch.dll" GUID = "2d8d1bb5-3f0c-4d5a-9d59-934463c06d35" def request(base, token, method, route, payload=None, content_type="application/json"): url = base.rstrip("/") + "/api/v1" + route data = None if payload is not None: data = json.dumps(payload).encode() if content_type == "application/json" else payload headers = {"Authorization": "token " + token, "Accept": "application/json"} if data is not None: headers["Content-Type"] = content_type req = urllib.request.Request(url, data=data, method=method, headers=headers) try: with urllib.request.urlopen(req, timeout=60) as resp: raw = resp.read() return json.loads(raw) if raw else {} except urllib.error.HTTPError as error: if error.code == 404: return None raise RuntimeError(f"{method} {route}: HTTP {error.code}: {error.read()[:500]!r}") from error def main(): parser = argparse.ArgumentParser() parser.add_argument("--publish", action="store_true", help="Publish to a separate public Gitea distribution repository") args = parser.parse_args() project = PROJECT.read_text() m = re.search(r"([^<]+)", project) if not m: sys.exit("Missing project Version") version = m.group(1) tag = os.environ.get("RELEASE_TAG", "v" + version) if tag != "v" + version: sys.exit(f"Release tag {tag!r} does not match project version v{version}") if not DLL.is_file(): sys.exit(f"Build DLL first: {DLL}") dist = ROOT / "dist" dist.mkdir(exist_ok=True) archive = dist / f"SceneSearch-{version}.zip" with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as z: z.write(DLL, "Jellyfin.Plugin.SceneSearch.dll") checksum = hashlib.md5(archive.read_bytes()).hexdigest() # Jellyfin catalog schema requires MD5. print(f"Packaged {archive} (MD5 {checksum})") if not args.publish: return base = os.environ["GITEA_URL"].rstrip("/") repo = os.environ["GITEA_REPO"].strip("/") token = os.environ["GITEA_TOKEN"] quoted_repo = "/repos/" + "/".join(urllib.parse.quote(x, safe="") for x in repo.split("/")) info = request(base, token, "GET", quoted_repo) if not info: sys.exit("Gitea distribution repository does not exist") if info.get("private"): sys.exit("Refusing to publish catalog into a private Gitea repository") release_route = quoted_repo + "/releases/tags/" + urllib.parse.quote(tag, safe="") release = request(base, token, "GET", release_route) if release is None: release = request(base, token, "POST", quoted_repo + "/releases", { "tag_name": tag, "name": f"Scene Search {version}", "body": f"Jellyfin Scene Search plugin {version}. Requires the separate Scene Search service.", "draft": False, "prerelease": False, }) assets = release.get("assets", []) name = archive.name asset = next((a for a in assets if a.get("name") == name), None) if asset is None: route = quoted_repo + f"/releases/{release['id']}/assets?name=" + urllib.parse.quote(name) asset = request(base, token, "POST", route, archive.read_bytes(), "application/octet-stream") url = asset.get("browser_download_url") if not url or not url.startswith(base + "/"): sys.exit("Gitea did not supply a valid public release URL") manifest_route = quoted_repo + "/contents/manifest.json" existing = request(base, token, "GET", manifest_route) if existing: catalog = json.loads(base64.b64decode(existing["content"]).decode()) else: catalog = [] entries = [x for x in catalog if x.get("guid", "").lower() == GUID] if len(entries) > 1: sys.exit("Duplicate plugin GUID in distribution manifest") plugin = entries[0] if entries else { "guid": GUID, "name": "Scene Search", "description": "Find scenes within your Jellyfin libraries using semantic search.", "overview": "Jellyfin integration for the separately hosted Scene Search service.", "owner": "MrSleeps", "category": "General", "versions": [], } record = { "version": version, "changelog": f"Scene Search {version}", "targetAbi": "12.0.0.0", "sourceUrl": url, "checksum": checksum, "timestamp": dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"), } plugin["versions"] = [record] + [v for v in plugin["versions"] if v["version"] != version] if not entries: catalog.append(plugin) content = base64.b64encode((json.dumps(catalog, indent=2) + "\n").encode()).decode() body = {"content": content, "message": f"Publish Scene Search {version}"} if existing: body["sha"] = existing["sha"] result = request(base, token, "PUT" if existing else "POST", manifest_route, body) if not result: sys.exit("Failed to update manifest") print(f"Published release {url}") print(f"Jellyfin repository: {base}/{repo}/raw/branch/{info.get('default_branch', 'main')}/manifest.json") if __name__ == "__main__": main()