feat: Recently Added Guard plugin for Jellyfin 12.2
Keeps Radarr/Sonarr quality upgrades out of "Recently Added" by keeping a ledger of the first date added per logical medium (movie or episode, identified by provider ids and the series) and restoring it on upgrades. - core logic without Jellyfin types (Reconciler, MediaKeys, GuardProcessor) - thread-safe ledger with atomic, debounced persistence - event processing via channel and background worker, seed on first start - scheduled task "Ledger synchronisieren", status API, configuration page - xUnit tests and a Jellyfin 12.2 integration test - Gitea workflows: build/test, release with jprm and manifest branch Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
d431486095
38 files changed
+4396
No files matched your search
@@ -0,0 +1,47 @@
|
||||
# Build and test on every push (except the CI-maintained `manifest` branch) and on pull requests.
|
||||
# Runner: Gitea act_runner with Docker. The job runs in the .NET 10 SDK image, which has no Node.js,
|
||||
# so JavaScript actions such as actions/checkout cannot run; the checkout uses plain git.
|
||||
name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore: [manifest]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/dotnet/sdk:10.0
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
env:
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
|
||||
DOTNET_NOLOGO: "1"
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
git config --global --add safe.directory '*'
|
||||
git init -q .
|
||||
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
|
||||
auth="Authorization: basic $(printf 'x-access-token:%s' "$TOKEN" | base64 -w0)"
|
||||
git -c http.extraHeader="$auth" fetch -q --depth 1 origin "$GITHUB_SHA"
|
||||
git checkout -q FETCH_HEAD
|
||||
git log -1 --oneline
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore
|
||||
|
||||
# Directory.Build.props treats warnings as errors in Release.
|
||||
- name: Build
|
||||
run: dotnet build -c Release --no-restore
|
||||
|
||||
- name: Test
|
||||
run: dotnet test -c Release --no-build
|
||||
@@ -0,0 +1,195 @@
|
||||
# Release on tag vA.B.C.D (Jellyfin plugins use four-part versions):
|
||||
# 1. test, 2. build the plugin ZIP with jprm (DLL + meta.json only), 3. publish a Gitea release
|
||||
# with the ZIP, 4. add the version to manifest.json on the `manifest` branch (the URL Jellyfin reads),
|
||||
# 5. check that manifest and ZIP are reachable anonymously and the MD5 matches.
|
||||
# The automatic GITEA_TOKEN does all of this; Gitea never starts workflows for pushes made with it,
|
||||
# so the manifest commit triggers no further run.
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
concurrency:
|
||||
group: recently-added-guard-release
|
||||
cancel-in-progress: false
|
||||
|
||||
# contents: write = Code (manifest branch) + Releases
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
MANIFEST_BRANCH: manifest
|
||||
JPRM_VERSION: "1.1.0"
|
||||
PLUGIN_GUID: 6fdfedef-adde-4ac3-a873-62a057c95574
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/dotnet/sdk:10.0
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
env:
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
|
||||
DOTNET_NOLOGO: "1"
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
PYTHONUTF8: "1"
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
steps:
|
||||
# The SDK image has no Node.js, so actions/checkout cannot run; plain git instead.
|
||||
- name: Checkout tag
|
||||
run: |
|
||||
git config --global --add safe.directory '*'
|
||||
git init -q .
|
||||
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
|
||||
git config http.extraHeader "Authorization: basic $(printf 'x-access-token:%s' "$TOKEN" | base64 -w0)"
|
||||
git fetch -q --tags origin "+refs/heads/*:refs/remotes/origin/*"
|
||||
git checkout -q "$GITHUB_SHA"
|
||||
git log -1 --oneline
|
||||
|
||||
- name: Version and changelog
|
||||
id: meta
|
||||
run: |
|
||||
tag="$GITHUB_REF_NAME"
|
||||
version="${tag#v}"
|
||||
if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Tag $tag hat nicht das Format vA.B.C.D" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Changelog: message of an annotated tag, otherwise the commit subjects since the previous tag.
|
||||
: > changelog.md
|
||||
if [ "$(git cat-file -t "refs/tags/$tag")" = tag ]; then
|
||||
git tag -l --format='%(contents)' "$tag" | sed '/-----BEGIN PGP SIGNATURE-----/,$d' > changelog.md
|
||||
fi
|
||||
if ! grep -q '[^[:space:]]' changelog.md; then
|
||||
previous=$(git describe --tags --abbrev=0 --match 'v*' "$tag^" 2>/dev/null || true)
|
||||
git log --no-merges --pretty='- %s' "${previous:+$previous..}$tag" > changelog.md
|
||||
fi
|
||||
cat changelog.md
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Test
|
||||
run: dotnet test -c Release
|
||||
|
||||
- name: Install jprm
|
||||
run: |
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq --no-install-recommends python3-venv > /dev/null
|
||||
python3 -m venv /opt/jprm
|
||||
/opt/jprm/bin/pip install -q "jprm==$JPRM_VERSION"
|
||||
|
||||
- name: Build plugin ZIP
|
||||
id: build
|
||||
env:
|
||||
VERSION: ${{ steps.meta.outputs.version }}
|
||||
run: |
|
||||
# Version and changelog go through build.yaml into meta.json (only this CI checkout changes).
|
||||
/opt/jprm/bin/python - "$VERSION" <<'EOF'
|
||||
import sys
|
||||
import yaml
|
||||
|
||||
with open("build.yaml", encoding="utf-8") as fh:
|
||||
config = yaml.safe_load(fh)
|
||||
with open("changelog.md", encoding="utf-8") as fh:
|
||||
config["changelog"] = fh.read().strip() or "Version " + sys.argv[1]
|
||||
config["version"] = sys.argv[1]
|
||||
with open("build.yaml", "w", encoding="utf-8") as fh:
|
||||
yaml.safe_dump(config, fh, allow_unicode=True, sort_keys=False)
|
||||
EOF
|
||||
|
||||
mkdir -p artifacts
|
||||
zip=$(/opt/jprm/bin/jprm plugin build . --output artifacts --version "$VERSION")
|
||||
|
||||
contents=$(/opt/jprm/bin/python -c 'import sys, zipfile; print(" ".join(sorted(zipfile.ZipFile(sys.argv[1]).namelist())))' "$zip")
|
||||
if [ "$contents" != "Jellyfin.Plugin.RecentlyAddedGuard.dll meta.json" ]; then
|
||||
echo "Unerwarteter ZIP-Inhalt: $contents" >&2
|
||||
exit 1
|
||||
fi
|
||||
md5sum "$zip"
|
||||
echo "zip=$(realpath "$zip")" >> "$GITHUB_OUTPUT"
|
||||
echo "name=$(basename "$zip")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish Gitea release
|
||||
id: release
|
||||
env:
|
||||
VERSION: ${{ steps.meta.outputs.version }}
|
||||
ZIP: ${{ steps.build.outputs.zip }}
|
||||
ASSET: ${{ steps.build.outputs.name }}
|
||||
run: |
|
||||
api="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
|
||||
auth=(-H "Authorization: token $TOKEN")
|
||||
json() { /opt/jprm/bin/python -c "import json, sys; data = json.load(sys.stdin); $1" "${@:2}"; }
|
||||
|
||||
# A re-run reuses the release of the tag and replaces the asset.
|
||||
if ! release=$(curl -fsS "${auth[@]}" "$api/releases/tags/$GITHUB_REF_NAME"); then
|
||||
body=$(/opt/jprm/bin/python -c 'import json, sys; print(json.dumps({"tag_name": sys.argv[1], "name": "Recently Added Guard " + sys.argv[2], "body": open("changelog.md", encoding="utf-8").read()}))' "$GITHUB_REF_NAME" "$VERSION")
|
||||
release=$(curl -fsS -X POST "${auth[@]}" -H "Content-Type: application/json" -d "$body" "$api/releases")
|
||||
fi
|
||||
release_id=$(printf '%s' "$release" | json 'print(data["id"])')
|
||||
for asset_id in $(printf '%s' "$release" | json '[print(a["id"]) for a in data["assets"] if a["name"] == sys.argv[1]]' "$ASSET"); do
|
||||
curl -fsS -X DELETE "${auth[@]}" "$api/releases/$release_id/assets/$asset_id"
|
||||
done
|
||||
|
||||
asset=$(curl -fsS -X POST "${auth[@]}" -F "attachment=@$ZIP" "$api/releases/$release_id/assets?name=$ASSET")
|
||||
url=$(printf '%s' "$asset" | json 'print(data["browser_download_url"])')
|
||||
echo "Release-Asset: $url"
|
||||
echo "url=$url" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Update manifest.json
|
||||
env:
|
||||
VERSION: ${{ steps.meta.outputs.version }}
|
||||
ZIP: ${{ steps.build.outputs.zip }}
|
||||
URL: ${{ steps.release.outputs.url }}
|
||||
run: |
|
||||
workdir=$(mktemp -d)
|
||||
if git show-ref --verify --quiet "refs/remotes/origin/$MANIFEST_BRANCH"; then
|
||||
git worktree add -q --detach "$workdir" "origin/$MANIFEST_BRANCH"
|
||||
else
|
||||
git worktree add -q --orphan -b "$MANIFEST_BRANCH" "$workdir"
|
||||
fi
|
||||
|
||||
cd "$workdir"
|
||||
[ -f manifest.json ] || echo '[]' > manifest.json
|
||||
# Adds the version in front (sorted descending) and keeps older versions; MD5 of the ZIP as checksum.
|
||||
/opt/jprm/bin/jprm repo add manifest.json "$ZIP" --plugin-url "$URL"
|
||||
git add manifest.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "manifest.json unverändert"
|
||||
exit 0
|
||||
fi
|
||||
git -c user.name="Gitea Actions" -c user.email="actions@noreply.git.menzel.center" \
|
||||
commit -q -m "manifest: Recently Added Guard $VERSION"
|
||||
git push -q origin "HEAD:refs/heads/$MANIFEST_BRANCH"
|
||||
|
||||
- name: Verify anonymous download and checksum
|
||||
env:
|
||||
VERSION: ${{ steps.meta.outputs.version }}
|
||||
URL: ${{ steps.release.outputs.url }}
|
||||
run: |
|
||||
manifest_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/branch/$MANIFEST_BRANCH/manifest.json"
|
||||
# No credentials: Jellyfin fetches both anonymously.
|
||||
curl -fsSL "$manifest_url" -o /tmp/manifest.json
|
||||
curl -fsSL "$URL" -o /tmp/plugin.zip
|
||||
/opt/jprm/bin/python - "$VERSION" "$URL" <<'EOF'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
version, url = sys.argv[1], sys.argv[2]
|
||||
with open("/tmp/manifest.json", encoding="utf-8") as fh:
|
||||
manifest = json.load(fh)
|
||||
plugin = next(p for p in manifest if p["guid"] == os.environ["PLUGIN_GUID"])
|
||||
entry = next(v for v in plugin["versions"] if v["version"] == version)
|
||||
with open("/tmp/plugin.zip", "rb") as fh:
|
||||
md5 = hashlib.md5(fh.read()).hexdigest()
|
||||
assert plugin["versions"][0]["version"] == version, "neue Version steht nicht vorne"
|
||||
assert entry["sourceUrl"] == url, (entry["sourceUrl"], url)
|
||||
assert entry["checksum"] == md5, (entry["checksum"], md5)
|
||||
print(f"OK: {version} -> {url}, MD5 {md5}, targetAbi {entry['targetAbi']}")
|
||||
EOF
|
||||
echo "Repository-URL für Jellyfin: $manifest_url"
|
||||
Reference in new issue
Block a user