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