mirror of
https://github.com/MrSleeps/jellyfin-scene-search.git
synced 2026-11-13 13:41:24 +00:00
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:
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user