Merge pull request #61 from MrSleeps/feature/multi-plugin-gitea-catalog

Support independent Scene Search and Facial Recognition releases in one Jellyfin catalog
This commit is contained in:
Sleeps
2026-10-15 17:46:45 +01:00
committed by GitHub
2 changed files with 212 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
name: Publish Jellyfin plugins
on:
workflow_dispatch:
inputs:
plugin:
description: "Plugin to publish"
required: true
type: choice
options:
- scene-search
- facial-recognition
version:
description: "Plugin version, e.g. 0.1.5.0 or 0.1.0.0"
required: true
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Configure SSH for private GitHub
env:
DEPLOY_KEY: ${{ secrets.SCENE_SEARCH_DEPLOY_KEY }}
FACE_DEPLOY_KEY: ${{ secrets.FACIAL_RECOGNITION_DEPLOY_KEY }}
run: |
set -eu
test -n "$DEPLOY_KEY"
install -m 700 -d ~/.ssh
printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
if [ -n "$FACE_DEPLOY_KEY" ]; then
printf '%s\n' "$FACE_DEPLOY_KEY" > ~/.ssh/facial_deploy_key
chmod 600 ~/.ssh/facial_deploy_key
fi
ssh-keyscan -H github.com >> ~/.ssh/known_hosts
- name: Clone source repositories
env:
GIT_SSH_COMMAND: "ssh -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes"
PLUGIN: ${{ inputs.plugin }}
VERSION: ${{ inputs.version }}
run: |
set -eu
git clone --depth 1 git@github.com:MrSleeps/jellyfin-scene-search.git source
if [ "$PLUGIN" = "facial-recognition" ]; then
test -s ~/.ssh/facial_deploy_key || { echo "Set FACIAL_RECOGNITION_DEPLOY_KEY secret"; exit 1; }
GIT_SSH_COMMAND="ssh -i ~/.ssh/facial_deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes" git clone --depth 1 git@github.com:MrSleeps/jellyfin-facial-recognition.git facial
test "$(python3 -c 'import json;print(json.load(open("facial/jellyfin-plugin/manifest.json"))["version"])')" = "$VERSION"
else
test "$(sed -n 's/.*<Version>\(.*\)<\/Version>.*/\1/p' source/jellyfin-plugin/Jellyfin.Plugin.SceneSearch/Jellyfin.Plugin.SceneSearch.csproj | head -1)" = "$VERSION"
fi
- name: Install .NET 10 SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Compile, package and publish
env:
PLUGIN: ${{ inputs.plugin }}
VERSION: ${{ inputs.version }}
GITEA_URL: https://git.trufflemonkey.co.uk
GITEA_REPO: sleeps/jellyfin-plugins
GITEA_TOKEN: ${{ secrets.SCENE_SEARCH_RELEASE_TOKEN }}
run: |
set -eu
if [ "$PLUGIN" = "scene-search" ]; then
PROJECT=source/jellyfin-plugin/Jellyfin.Plugin.SceneSearch/Jellyfin.Plugin.SceneSearch.csproj
DLL=source/jellyfin-plugin/Jellyfin.Plugin.SceneSearch/bin/Release/net10.0/Jellyfin.Plugin.SceneSearch.dll
dotnet build "$PROJECT" -c Release --nologo
python3 source/scripts/publish-multi-plugin.py --plugin "$PLUGIN" --version "$VERSION" --dll "$DLL"
else
PROJECT=facial/jellyfin-plugin/Jellyfin.Plugin.FacialRecognition/Jellyfin.Plugin.FacialRecognition.csproj
DLL=facial/jellyfin-plugin/Jellyfin.Plugin.FacialRecognition/bin/Release/net10.0/Jellyfin.Plugin.FacialRecognition.dll
dotnet build "$PROJECT" -c Release --nologo
python3 source/scripts/publish-multi-plugin.py --plugin "$PLUGIN" --version "$VERSION" --dll "$DLL" --plugin-manifest facial/jellyfin-plugin/manifest.json
fi
+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()