Merge pull request #60 from MrSleeps/feature/jellyfin-gitea-plugin-distribution

Publish Scene Search as a Jellyfin catalog plugin via Gitea
This commit is contained in:
Sleeps
2026-10-15 16:54:06 +01:00
committed by GitHub
3 changed files with 238 additions and 0 deletions
@@ -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
+62
View File
@@ -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 `<Version>` 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.
+126
View File
@@ -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"<Version>([^<]+)</Version>", 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()