From c1aaf63c415c3778b4a4ceb10e6e582adde67bc7 Mon Sep 17 00:00:00 2001 From: Sleeps Date: Thu, 15 Oct 2026 16:52:03 +0100 Subject: [PATCH 1/3] Add Gitea release packager and Jellyfin catalog publisher --- scripts/publish-jellyfin-plugin.py | 126 +++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 scripts/publish-jellyfin-plugin.py 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() From d51e82298aac9c2e45d399ce4b154d0182fdb32e Mon Sep 17 00:00:00 2001 From: Sleeps Date: Thu, 15 Oct 2026 16:52:20 +0100 Subject: [PATCH 2/3] Add version-tagged Gitea plugin release workflow --- .github/workflows/publish-jellyfin-plugin.yml | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/workflows/publish-jellyfin-plugin.yml diff --git a/.github/workflows/publish-jellyfin-plugin.yml b/.github/workflows/publish-jellyfin-plugin.yml new file mode 100644 index 0000000..6bd0844 --- /dev/null +++ b/.github/workflows/publish-jellyfin-plugin.yml @@ -0,0 +1,50 @@ +name: Publish Jellyfin plugin to Gitea + +on: + push: + tags: + - 'v[0-9]*' + workflow_dispatch: + inputs: + release_tag: + description: 'Existing version tag (e.g. v0.1.5.0)' + required: true + type: string + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Check out tagged source + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref }} + - name: Set release tag + run: echo "RELEASE_TAG=${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }}" >> "$GITHUB_ENV" + - name: Install .NET 10 SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + - name: Build Jellyfin plugin + run: dotnet build jellyfin-plugin/Jellyfin.Plugin.SceneSearch/Jellyfin.Plugin.SceneSearch.csproj --configuration Release --nologo + - name: Package plugin + run: python3 scripts/publish-jellyfin-plugin.py + - name: Upload package for inspection + uses: actions/upload-artifact@v4 + with: + name: scene-search-plugin + path: dist/*.zip + - name: Publish release and catalog on Gitea + env: + GITEA_URL: ${{ vars.GITEA_URL }} + GITEA_REPO: ${{ vars.GITEA_DISTRIBUTION_REPO }} + GITEA_TOKEN: ${{ secrets.GITEA_RELEASE_TOKEN }} + run: | + test -n "$GITEA_URL" && test -n "$GITEA_REPO" && test -n "$GITEA_TOKEN" || { + echo 'Configure GITEA_URL, GITEA_DISTRIBUTION_REPO and GITEA_RELEASE_TOKEN first.' + exit 1 + } + python3 scripts/publish-jellyfin-plugin.py --publish From 3b9324d409bfa198ca6c45abb67ea36f71d6f369 Mon Sep 17 00:00:00 2001 From: Sleeps Date: Thu, 15 Oct 2026 16:52:51 +0100 Subject: [PATCH 3/3] Document private-source public-binary Jellyfin distribution --- docs/gitea-jellyfin-distribution.md | 62 +++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 docs/gitea-jellyfin-distribution.md diff --git a/docs/gitea-jellyfin-distribution.md b/docs/gitea-jellyfin-distribution.md new file mode 100644 index 0000000..b1a540b --- /dev/null +++ b/docs/gitea-jellyfin-distribution.md @@ -0,0 +1,62 @@ +# Distribute the Jellyfin plugin using Gitea + +This project is **private** on GitHub and mirrored to private Gitea. Do not make that +source mirror public. Jellyfin needs a **separate public Gitea repository** +containing only a catalog manifest and binary release attachments. + +## One-time setup + +1. In Gitea (`https://git.trufflemonkey.co.uk`), create an empty **public** + distribution repository, e.g. `sleeps/jellyfin-plugins`. It must not be a + pull mirror. Initialise a default `main` branch (for example with a README). +2. Create a Gitea access token with write access limited to that **public** + distribution repository (repository contents and releases). Store it as a + **GitHub Actions secret**, `GITEA_RELEASE_TOKEN`. Never commit the token. +3. In this private GitHub repository set the Actions **variables**: + - `GITEA_URL` = `https://git.trufflemonkey.co.uk` + - `GITEA_DISTRIBUTION_REPO` = `sleeps/jellyfin-plugins` +4. Before tagging, update `` in + `jellyfin-plugin/Jellyfin.Plugin.SceneSearch/Jellyfin.Plugin.SceneSearch.csproj` + and merge it to `main`. +5. Create/push the matching tag, e.g. `v0.1.5.0`, on the commit you want + to release. Alternatively run **Publish Jellyfin plugin to Gitea** from + GitHub Actions, choosing an existing version tag. + +The workflow builds with .NET 10, packages the plugin DLL, creates the release +on Gitea and generates/updates `manifest.json` in the distribution repository. +Previous versions remain listed. The `targetAbi` is currently 12.0.0.0 to match +the Jellyfin 12 dependencies in the project. Update this if the plugin's Jellyfin +dependency changes. + +Then add this repository URL in Jellyfin's **Dashboard → Plugins → Repositories**: + +```text +https://git.trufflemonkey.co.uk/sleeps/jellyfin-plugins/raw/branch/main/manifest.json +``` + +The download ZIP and this raw JSON URL must both work **without signing in**. +Wait until the release workflow has successfully completed before adding +the URL. The private Gitea source mirror URL is *not* a Jellyfin catalog URL. + +## Local packaging / verification + +```sh +dotnet build jellyfin-plugin/Jellyfin.Plugin.SceneSearch/Jellyfin.Plugin.SceneSearch.csproj -c Release +python3 scripts/publish-jellyfin-plugin.py +unzip -l dist/SceneSearch-0.1.5.0.zip +``` + +To publish manually, set `RELEASE_TAG`, `GITEA_URL`, `GITEA_REPO` and +`GITEA_TOKEN`, then invoke the script with `--publish`. + +## Important limitations + +- The plugin requires the **separate Scene Search API/service**; installing the + DLL from Jellyfin does not install Docker, Meilisearch, model files, etc. +- Jellyfin installation/update may require restarting the Jellyfin server. +- The release ZIP contains the plugin DLL, **not the private source tree**. +- Publishing a same-version tag twice may replace the manifest checksum while + retaining a previously uploaded asset. Publish new changes under a **new + version/tag**, rather than reusing an already published version. +- Gitea's public distribution repository must be protected against unauthorised + changes; the token grants release and manifest write permissions.