diff --git a/scripts/publish-jellyfin-plugin.py b/scripts/publish-jellyfin-plugin.py new file mode 100644 index 0000000..87b6180 --- /dev/null +++ b/scripts/publish-jellyfin-plugin.py @@ -0,0 +1,126 @@ +#!/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()