Add multi-plugin Gitea catalog release publisher

This commit is contained in:
Sleeps
2026-10-15 17:45:08 +01:00
parent d5246e1608
commit 53304ac4bc
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""Publish independently versioned Jellyfin plugins to one public Gitea catalog."""
import argparse
import base64
from datetime import datetime, timezone
import hashlib
import json
import os
from pathlib import Path
import urllib.error
import urllib.parse
import urllib.request
import zipfile
PLUGINS = {
"scene-search": {
"guid": "2d8d1bb5-3f0c-4d5a-9d59-934463c06d35",
"name": "Scene Search", "dll": "Jellyfin.Plugin.SceneSearch.dll",
"description": "Semantic scene search for Jellyfin media.",
"overview": "Connects Jellyfin to a separately installed Scene Search service.",
},
"facial-recognition": {
"guid": "bc8771b0-5ad9-4c87-9f37-6c6e5658c425",
"name": "Facial Recognition", "dll": "Jellyfin.Plugin.FacialRecognition.dll",
"description": "Review and manage recognized faces in Jellyfin.",
"overview": "Connects Jellyfin to a separately installed Facial Recognition service.",
},
}
def api(base, token, method, route, payload=None, content_type="application/json"):
url = base.rstrip("/") + "/api/v1" + route
if isinstance(payload, dict):
payload = json.dumps(payload).encode()
headers = {"Authorization": "token " + token, "Accept": "application/json"}
if payload is not None:
headers["Content-Type"] = content_type
req = urllib.request.Request(url, data=payload, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=90) as resp:
data = resp.read()
return json.loads(data) if data else {}
except urllib.error.HTTPError as exc:
if exc.code == 404:
return None
raise RuntimeError(f"Gitea {method} {route}: HTTP {exc.code}: {exc.read()[:500]!r}") from exc
def main():
p = argparse.ArgumentParser()
p.add_argument("--plugin", required=True, choices=PLUGINS)
p.add_argument("--version", required=True)
p.add_argument("--dll", required=True, type=Path)
p.add_argument("--plugin-manifest", type=Path,
help="Optional installed-plugin manifest included in ZIP")
args = p.parse_args()
data = PLUGINS[args.plugin]
if not args.dll.is_file():
p.error(f"DLL does not exist: {args.dll}")
if not args.version or not all(c.isdigit() or c == "." for c in args.version):
p.error("Invalid version (expected e.g. 0.1.5.0)")
tag = f"{args.plugin}-v{args.version}"
archive_name = f"{args.plugin}-{args.version}.zip"
out = Path("dist")
out.mkdir(exist_ok=True)
archive = out / archive_name
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as z:
z.write(args.dll, data["dll"])
if args.plugin_manifest:
metadata = json.loads(args.plugin_manifest.read_text())
if metadata.get("guid", "").lower() != data["guid"] or metadata.get("version") != args.version:
p.error("Installed-plugin manifest GUID/version does not match build")
z.write(args.plugin_manifest, "manifest.json")
checksum = hashlib.md5(archive.read_bytes()).hexdigest() # Jellyfin catalog specifies MD5.
print(f"Built {archive} MD5={checksum}")
base = os.environ["GITEA_URL"].rstrip("/")
repo = os.environ["GITEA_REPO"].strip("/")
token = os.environ["GITEA_TOKEN"]
repo_route = "/repos/" + "/".join(urllib.parse.quote(x, safe="") for x in repo.split("/"))
repo_info = api(base, token, "GET", repo_route)
if not repo_info or repo_info.get("private"):
p.error("Gitea distribution repository must exist and be public")
release = api(base, token, "GET", repo_route + "/releases/tags/" + urllib.parse.quote(tag, safe=""))
if release is None:
release = api(base, token, "POST", repo_route + "/releases", {
"tag_name": tag, "name": f"{data['name']} {args.version}",
"body": f"{data['name']} {args.version}. Requires its separate backend service.",
"draft": False, "prerelease": False,
})
asset = next((a for a in release.get("assets", []) if a.get("name") == archive_name), None)
if asset is not None:
# Never alter already published version: tag immutability prevents stale downloads.
p.error(f"Release asset already exists for {tag}; publish a new plugin version instead")
boundary = "SceneSearchGiteaUploadBoundary"
body = (
(f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="attachment"; filename="{archive_name}"\r\n'
"Content-Type: application/zip\r\n\r\n").encode()
+ archive.read_bytes()
+ (f"\r\n--{boundary}--\r\n").encode()
)
asset = api(base, token, "POST",
repo_route + f"/releases/{release['id']}/assets?name=" + urllib.parse.quote(archive_name),
body, f"multipart/form-data; boundary={boundary}")
asset_url = asset.get("browser_download_url") if asset else None
if not asset_url or not asset_url.startswith(base + "/"):
p.error("Release asset has no public download URL")
path = repo_route + "/contents/manifest.json"
existing = api(base, token, "GET", path)
catalog = json.loads(base64.b64decode(existing["content"])) if existing else []
plugin_entries = [x for x in catalog if x.get("guid", "").lower() == data["guid"]]
if len(plugin_entries) > 1:
p.error("Duplicate plugin GUID in catalog")
plugin = plugin_entries[0] if plugin_entries else {
"guid": data["guid"], "name": data["name"], "description": data["description"],
"overview": data["overview"], "owner": "MrSleeps", "category": "General", "versions": [],
}
version_entry = {
"version": args.version, "changelog": f"{data['name']} {args.version}",
"targetAbi": "12.0.0.0", "sourceUrl": asset_url, "checksum": checksum,
"timestamp": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"),
}
plugin["versions"] = [version_entry] + [v for v in plugin["versions"] if v["version"] != args.version]
if not plugin_entries:
catalog.append(plugin)
content = base64.b64encode((json.dumps(catalog, indent=2) + "\n").encode()).decode()
body = {"content": content, "message": f"Publish {data['name']} {args.version}"}
if existing:
body["sha"] = existing["sha"]
api(base, token, "PUT" if existing else "POST", path, body)
print(f"Published {asset_url}")
print(f"Jellyfin catalog: {base}/{repo}/raw/branch/{repo_info.get('default_branch','main')}/manifest.json")
if __name__ == "__main__":
main()