Compare commits

...
5 Commits
Author SHA1 Message Date
dtourolleandClaude Opus 5.5 db9461cf59 ci: read the zip list with cut, not a bash-only IFS
🏗️ Build Plugin / build (push) Successful in 1m41s
Nightly Build / nightly-build (push) Successful in 1m18s
🧪 Test Plugin / test (push) Successful in 46s
🚀 Release Plugin / build-and-release (push) Successful in 1m26s
The upload loop used IFS=$'\t', but the job steps run under /bin/sh,
which is dash in the builder image. dash does not expand $'\t', so IFS
became the characters $, \ and t and every path was split on "t":
"artifacts/srfplay_1.2.0.9.zip" reached curl as "s/srfplay_...", which
failed with exit 26 after the release had already been created. This
broke both the v1.2.0 release and the nightly.

cut -f3 is plain POSIX. Checked by running the upload and manifest
steps under the builder image's dash with curl stubbed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 02:15:09 +02:00
dtourolleandClaude Opus 5.5 b0c067ed41 Add a Docker test harness for any Jellyfin version
🏗️ Build Plugin / build (push) Successful in 1m15s
Nightly Build / nightly-build (push) Failing after 1m14s
🧪 Test Plugin / test (push) Successful in 40s
test-image.sh publishes the plugin on the host, bakes it into a stock
jellyfin/jellyfin image via Dockerfile.test, and runs it on :8096, with
optional egress through a LAN gateway (DOCKER_GATEWAY, e.g. the Swiss
VPN box) for geo-blocked streams.

JELLYFIN_TAG (default 12) selects the image and the matching plugin
build: 10.9/10.10 -> net8.0, 10.11 -> net9.0, 12 -> net10.0, with the
generated meta.json targetAbi to match. Images are tagged per version
(srfplay-test:<tag>) so several can coexist.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 01:44:34 +02:00
dtourolleandClaude Opus 5.5 582c94f5f9 Build one plugin per Jellyfin generation (10.9, 10.11, 12)
🏗️ Build Plugin / build (push) Successful in 2m27s
Nightly Build / nightly-build (push) Failing after 1m18s
🧪 Test Plugin / test (push) Successful in 43s
🚀 Release Plugin / build-and-release (push) Failing after 1m12s
The single net8.0 build compiled against Jellyfin 10.9.11 still loads on
10.11 and 12, so channels and browsing keep working, but 10.11 changed
APIs in ways that only fail at runtime. A binary audit of the DLL against
the real server assemblies found 9 broken references on both 10.11 and
12 (none on 10.9/10.10):

- TaskTriggerInfo.Type became an enum. GetDefaultTriggers throws, so
  Jellyfin silently gives every SRF task a 24h fallback trigger. The
  recording scheduler therefore ran about once a day and scheduled
  recordings never started.
- ILibraryManager.GetItemList changed its return type: the expiration
  check crashed on every run.
- Jellyfin.Data.Entities.User moved (IUserManager.Users, GetUserData,
  SaveUserData, PlaybackProgressEventArgs.Users): resume cleanup returned
  500 and the playback-stop guard threw on every stop.

Jellyfin 12 also rejects the legacy X-Emby-Token header with an empty
401, which the recordings UI surfaced as "Unexpected end of JSON input".
The pages now send Authorization: MediaBrowser Token="...", verified on
10.9, 10.11 and 12.

The plugin now multi-targets net8.0/net9.0/net10.0 against Jellyfin
10.9.11/10.11.0/12.0.0 (the oldest package of each line, so each DLL
loads on every patch release of it), with JELLYFIN_10_11_OR_GREATER and
JELLYFIN_12_OR_GREATER for the differences. Trigger construction moves
into Utilities/TaskTriggers so the version switch lives in one place.

.gitea/scripts/build-plugins.sh builds one zip per generation. jprm has
no targetAbi flag, so it rewrites build.yaml per flavour and restores
it. Jellyfin installs the highest version whose targetAbi it satisfies,
so the flavour code goes into the last version segment:

  release v1.2.0 -> 1.2.0.9 / 1.2.0.11 / 1.2.0.12
  nightly        -> 1.0.<date>.<run>09 / 11 / 12

The PR, nightly and release workflows use the script and publish one
manifest entry per zip. Verified end to end against a three-entry
manifest: 10.11 installs the .11 build, 12.1 installs the .12 build.
The existing .NET 10 builder image builds all three unchanged.

Supporting changes: the test project multi-targets too, because jprm
publishes the whole solution for one framework at a time, and it drops
its Microsoft.Extensions.Logging 8.0.1 pin, which is a NU1605 downgrade
under the newer Jellyfin packages. CA1873 (new in the .NET 10 analyzers)
is silenced beside CA1848. The release manifest step checks out
origin/master like the nightly does since 7e1973a.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 01:42:38 +02:00
Gitea Actions dc2dd09e49 Update manifest.json for version 1.1.2 2026-09-08 17:31:01 +00:00
Gitea Actions 3cbbd20e71 Nightly: 1.0.20260908.201 (d0d5d06) 2026-09-08 17:28:03 +00:00
22 changed files with 397 additions and 152 deletions
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Build one plugin zip per Jellyfin generation into ./artifacts.
#
# Jellyfin binds a plugin to the exact API it was compiled against, and 10.11 changed
# signatures that still compile but fail at runtime. So each generation gets its own DLL
# (see the TargetFrameworks comment in Jellyfin.Plugin.SRFPlay.csproj) and its own
# manifest entry. Jellyfin installs the highest version whose targetAbi is <= the server
# version, so the flavour code is folded into the version to keep them apart:
#
# net8.0 -> targetAbi 10.9.0.0 (Jellyfin 10.9 / 10.10) code 9
# net9.0 -> targetAbi 10.11.0.0 (Jellyfin 10.11) code 11
# net10.0 -> targetAbi 12.0.0.0 (Jellyfin 12) code 12
#
# Usage: build-plugins.sh <version-format>
# <version-format> is a printf format that receives the flavour code, e.g.
# "1.2.0.%d" -> 1.2.0.9, 1.2.0.11, 1.2.0.12 (release)
# "1.0.20260924.204%02d" -> 1.0.20260924.20409, ...20411, ...20412 (nightly)
#
# Writes artifacts/builds.tsv with one line per zip: version, targetAbi, zip path, md5.
set -euo pipefail
FORMAT="$1"
FLAVOURS=(
"net8.0 10.9.0.0 9"
"net9.0 10.11.0.0 11"
"net10.0 12.0.0.0 12"
)
mkdir -p artifacts
: > artifacts/builds.tsv
# jprm has no targetAbi flag and reads it from build.yaml, so rewrite it per flavour.
cp build.yaml build.yaml.orig
trap 'mv -f build.yaml.orig build.yaml' EXIT
for flavour in "${FLAVOURS[@]}"; do
read -r framework abi code <<< "$flavour"
# shellcheck disable=SC2059 # the format is the argument by design
version=$(printf "$FORMAT" "$code")
cp build.yaml.orig build.yaml
sed -i \
-e "s/^version:.*/version: \"${version}\"/" \
-e "s/^targetAbi:.*/targetAbi: \"${abi}\"/" \
-e "s/^framework:.*/framework: \"${framework}\"/" \
-e "s/^dotnet_framework:.*/dotnet_framework: \"${framework}\"/" \
build.yaml
echo ">> ${version}: ${framework}, targetAbi ${abi}"
jprm --verbosity=debug plugin build . --dotnet-framework "${framework}" --version "${version}"
zip="artifacts/srfplay_${version}.zip"
if [ ! -f "${zip}" ]; then
echo "jprm did not produce ${zip}" >&2
exit 1
fi
printf '%s\t%s\t%s\t%s\n' "${version}" "${abi}" "${zip}" "$(md5sum "${zip}" | awk '{print $1}')" >> artifacts/builds.tsv
done
cat artifacts/builds.tsv
+6 -17
View File
@@ -51,14 +51,10 @@ jobs:
LABEL="master" LABEL="master"
fi fi
echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "format=${VERSION}%02d" >> $GITHUB_OUTPUT
echo "label=${LABEL}" >> $GITHUB_OUTPUT echo "label=${LABEL}" >> $GITHUB_OUTPUT
echo "Build version: ${VERSION} (${LABEL})" echo "Build version: ${VERSION} (${LABEL})"
- name: Set build version
working-directory: build-${{ github.run_id }}
run: |
sed -i "s/^version:.*/version: \"${{ steps.version.outputs.version }}\"/" build.yaml
- name: Build solution - name: Build solution
working-directory: build-${{ github.run_id }} working-directory: build-${{ github.run_id }}
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained /m:1 run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained /m:1
@@ -67,23 +63,16 @@ jobs:
working-directory: build-${{ github.run_id }} working-directory: build-${{ github.run_id }}
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal
- name: Build Jellyfin Plugin - name: Build Jellyfin Plugins
id: jprm
working-directory: build-${{ github.run_id }} working-directory: build-${{ github.run_id }}
run: | # One zip per Jellyfin generation (see .gitea/scripts/build-plugins.sh).
mkdir -p artifacts run: .gitea/scripts/build-plugins.sh "${{ steps.version.outputs.format }}"
jprm --verbosity=debug plugin build .
ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
LATEST="artifacts/srfplay_latest.zip"
cp "${ARTIFACT}" "${LATEST}"
echo "artifact=${LATEST}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT} -> ${LATEST}"
- name: Upload build artifact - name: Upload build artifacts
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: srfplay-${{ steps.version.outputs.label }}-${{ steps.version.outputs.version }} name: srfplay-${{ steps.version.outputs.label }}-${{ steps.version.outputs.version }}
path: build-${{ github.run_id }}/${{ steps.jprm.outputs.artifact }} path: build-${{ github.run_id }}/artifacts/*.zip
retention-days: 30 retention-days: 30
if-no-files-found: error if-no-files-found: error
+31 -51
View File
@@ -27,18 +27,15 @@ jobs:
# Date-based nightly version: 1.0.<YYYYMMDD>.<run_number> # Date-based nightly version: 1.0.<YYYYMMDD>.<run_number>
# The run_number suffix keeps multiple builds on the same day # The run_number suffix keeps multiple builds on the same day
# monotonically increasing so Jellyfin always offers the newest. # monotonically increasing so Jellyfin always offers the newest.
# Each Jellyfin generation gets its own build, so the last segment
# is <run_number><2-digit flavour code>, e.g. run 204 -> 20409/20411/20412
# (see .gitea/scripts/build-plugins.sh).
DATE=$(date -u +"%Y%m%d") DATE=$(date -u +"%Y%m%d")
VERSION="1.0.${DATE}.${{ github.run_number }}" VERSION="1.0.${DATE}.${{ github.run_number }}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "format=${VERSION}%02d" >> $GITHUB_OUTPUT
echo "Nightly version: ${VERSION}" echo "Nightly version: ${VERSION}"
- name: Set build version
working-directory: build-${{ github.run_id }}
run: |
VERSION="${{ steps.version.outputs.version }}"
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
cat build.yaml
- name: Restore dependencies - name: Restore dependencies
working-directory: build-${{ github.run_id }} working-directory: build-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
@@ -51,25 +48,9 @@ jobs:
working-directory: build-${{ github.run_id }} working-directory: build-${{ github.run_id }}
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal
- name: Build Jellyfin Plugin - name: Build Jellyfin Plugins
id: jprm
working-directory: build-${{ github.run_id }} working-directory: build-${{ github.run_id }}
run: | run: .gitea/scripts/build-plugins.sh "${{ steps.version.outputs.format }}"
mkdir -p artifacts
jprm --verbosity=debug plugin build .
ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
ARTIFACT_NAME=$(basename "${ARTIFACT}")
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT}"
- name: Calculate checksum
id: checksum
working-directory: build-${{ github.run_id }}
run: |
CHECKSUM=$(md5sum "${{ steps.jprm.outputs.artifact }}" | awk '{print $1}')
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
echo "Checksum: ${CHECKSUM}"
- name: Delete existing nightly release - name: Delete existing nightly release
working-directory: build-${{ github.run_id }} working-directory: build-${{ github.run_id }}
@@ -129,13 +110,15 @@ jobs:
exit 1 exit 1
fi fi
# Upload plugin artifact # Upload one plugin zip per Jellyfin generation
echo "Uploading plugin artifact..." cut -f3 artifacts/builds.tsv | while read -r ZIP; do
curl -f -X POST \ echo "Uploading ${ZIP}..."
-H "Authorization: token ${GITEA_TOKEN}" \ curl -f -X POST \
-H "Content-Type: application/zip" \ -H "Authorization: token ${GITEA_TOKEN}" \
--data-binary "@${{ steps.jprm.outputs.artifact }}" \ -H "Content-Type: application/zip" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=${{ steps.jprm.outputs.artifact_name }}" --data-binary "@${ZIP}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=$(basename "${ZIP}")"
done
# Upload build.yaml # Upload build.yaml
echo "Uploading build.yaml..." echo "Uploading build.yaml..."
@@ -155,12 +138,10 @@ jobs:
REPO_OWNER="${{ github.repository_owner }}" REPO_OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}" REPO_NAME="${{ github.event.repository.name }}"
GITEA_URL="${{ github.server_url }}" GITEA_URL="${{ github.server_url }}"
VERSION="${{ steps.version.outputs.version }}"
CHECKSUM="${{ steps.checksum.outputs.checksum }}"
ARTIFACT_NAME="${{ steps.jprm.outputs.artifact_name }}"
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
DOWNLOAD_URL="${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/latest/${ARTIFACT_NAME}"
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
# builds.tsv is read before the checkout below replaces the tree.
BUILDS=$(cat artifacts/builds.tsv)
git config user.name "Gitea Actions" git config user.name "Gitea Actions"
git config user.email "actions@gitea.tourolle.paris" git config user.email "actions@gitea.tourolle.paris"
@@ -178,25 +159,24 @@ jobs:
# edits must be master, not whatever this build started from. # edits must be master, not whatever this build started from.
git checkout -B master origin/master git checkout -B master origin/master
NEW_VERSION=$(cat <<EOF # One entry per Jellyfin generation; each server installs the newest
{ # entry whose targetAbi it satisfies.
"version": "${VERSION}", NEW_VERSIONS=$(echo "${BUILDS}" | jq -R -s \
"changelog": "Nightly build ${VERSION} (${SHORT_SHA})", --arg url "${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/latest" \
"targetAbi": "10.9.0.0", --arg sha "${SHORT_SHA}" --arg ts "${TIMESTAMP}" \
"sourceUrl": "${DOWNLOAD_URL}", 'split("\n") | map(select(length > 0) | split("\t")
"checksum": "${CHECKSUM}", | {version: .[0], changelog: "Nightly build \(.[0]) (\($sha))", targetAbi: .[1],
"timestamp": "${TIMESTAMP}" sourceUrl: "\($url)/\(.[2] | split("/") | last)", checksum: .[3], timestamp: $ts})
} | reverse')
EOF
)
# Prepend the new build and keep only the most recent 5 nightlies. # Prepend the new builds and keep only the most recent 5 nightlies
jq --argjson newver "${NEW_VERSION}" \ # (3 entries each, one per Jellyfin generation).
'.[0].versions = ([$newver] + .[0].versions)[0:5]' \ jq --argjson newvers "${NEW_VERSIONS}" \
'.[0].versions = ($newvers + .[0].versions)[0:15]' \
manifest-nightly.json > manifest.tmp && mv manifest.tmp manifest-nightly.json manifest-nightly.json > manifest.tmp && mv manifest.tmp manifest-nightly.json
git add manifest-nightly.json git add manifest-nightly.json
git commit -m "Nightly: ${VERSION} (${SHORT_SHA})" || echo "No changes to commit" git commit -m "Nightly: ${{ steps.version.outputs.version }} (${SHORT_SHA})" || echo "No changes to commit"
git push origin master git push origin master
- name: Cleanup - name: Cleanup
+28 -48
View File
@@ -35,13 +35,6 @@ jobs:
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
echo "Building version: ${VERSION}" echo "Building version: ${VERSION}"
- name: Update build.yaml with version
working-directory: release-${{ github.run_id }}
run: |
VERSION="${{ steps.get_version.outputs.version_number }}"
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
cat build.yaml
- name: Cache NuGet packages - name: Cache NuGet packages
uses: actions/cache@v3 uses: actions/cache@v3
with: with:
@@ -61,25 +54,11 @@ jobs:
working-directory: release-${{ github.run_id }} working-directory: release-${{ github.run_id }}
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal
- name: Build Jellyfin Plugin - name: Build Jellyfin Plugins
id: jprm
working-directory: release-${{ github.run_id }} working-directory: release-${{ github.run_id }}
run: | # v1.2.0 -> 1.2.0.9 / 1.2.0.11 / 1.2.0.12, one per Jellyfin generation
mkdir -p artifacts # (see .gitea/scripts/build-plugins.sh).
jprm --verbosity=debug plugin build ./ run: .gitea/scripts/build-plugins.sh "${{ steps.get_version.outputs.version_number }}.%d"
ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
ARTIFACT_NAME=$(basename "${ARTIFACT}")
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT}"
- name: Calculate checksum
id: checksum
working-directory: release-${{ github.run_id }}
run: |
CHECKSUM=$(md5sum "${{ steps.jprm.outputs.artifact }}" | awk '{print $1}')
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
echo "Checksum: ${CHECKSUM}"
- name: Create Release - name: Create Release
working-directory: release-${{ github.run_id }} working-directory: release-${{ github.run_id }}
@@ -111,13 +90,15 @@ jobs:
exit 1 exit 1
fi fi
# Upload plugin artifact # Upload one plugin zip per Jellyfin generation
echo "Uploading plugin artifact..." cut -f3 artifacts/builds.tsv | while read -r ZIP; do
curl -f -X POST \ echo "Uploading ${ZIP}..."
-H "Authorization: token ${GITEA_TOKEN}" \ curl -f -X POST \
-H "Content-Type: application/zip" \ -H "Authorization: token ${GITEA_TOKEN}" \
--data-binary "@${{ steps.jprm.outputs.artifact }}" \ -H "Content-Type: application/zip" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=${{ steps.jprm.outputs.artifact_name }}" --data-binary "@${ZIP}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=$(basename "${ZIP}")"
done
# Upload build.yaml # Upload build.yaml
echo "Uploading build.yaml..." echo "Uploading build.yaml..."
@@ -139,29 +120,28 @@ jobs:
REPO_NAME="${{ github.event.repository.name }}" REPO_NAME="${{ github.event.repository.name }}"
GITEA_URL="${{ github.server_url }}" GITEA_URL="${{ github.server_url }}"
VERSION="${{ steps.get_version.outputs.version_number }}" VERSION="${{ steps.get_version.outputs.version_number }}"
CHECKSUM="${{ steps.checksum.outputs.checksum }}"
ARTIFACT_NAME="${{ steps.jprm.outputs.artifact_name }}"
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
DOWNLOAD_URL="${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/${{ steps.get_version.outputs.version }}/${ARTIFACT_NAME}" # builds.tsv is read before the checkout below replaces the tree.
BUILDS=$(cat artifacts/builds.tsv)
git config user.name "Gitea Actions" git config user.name "Gitea Actions"
git config user.email "actions@gitea.tourolle.paris" git config user.email "actions@gitea.tourolle.paris"
git fetch origin master git fetch origin master
git checkout master # -B against origin/master for the same reason as the nightly: a bare
# `checkout master` edits the manifest on a stale base if master moved.
git checkout -B master origin/master
NEW_VERSION=$(cat <<EOF # One entry per Jellyfin generation; each server installs the newest
{ # entry whose targetAbi it satisfies.
"version": "${VERSION}", NEW_VERSIONS=$(echo "${BUILDS}" | jq -R -s \
"changelog": "Release ${VERSION}", --arg url "${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/${{ steps.get_version.outputs.version }}" \
"targetAbi": "10.9.0.0", --arg release "${VERSION}" --arg ts "${TIMESTAMP}" \
"sourceUrl": "${DOWNLOAD_URL}", 'split("\n") | map(select(length > 0) | split("\t")
"checksum": "${CHECKSUM}", | {version: .[0], changelog: "Release \($release)", targetAbi: .[1],
"timestamp": "${TIMESTAMP}" sourceUrl: "\($url)/\(.[2] | split("/") | last)", checksum: .[3], timestamp: $ts})
} | reverse')
EOF
)
jq --argjson newver "${NEW_VERSION}" '.[0].versions = [$newver] + .[0].versions' manifest.json > manifest.tmp && mv manifest.tmp manifest.json jq --argjson newvers "${NEW_VERSIONS}" '.[0].versions = $newvers + .[0].versions' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
git add manifest.json git add manifest.json
git commit -m "Update manifest.json for version ${VERSION}" git commit -m "Update manifest.json for version ${VERSION}"
git push origin master git push origin master
+1
View File
@@ -4,3 +4,4 @@ obj/
.idea/ .idea/
artifacts artifacts
*.log *.log
.test-plugin/
+23
View File
@@ -0,0 +1,23 @@
# Test image: stock Jellyfin with a prebuilt SRF Play plugin baked in.
#
# This does NOT build the plugin — build it first on the host (fast, warm
# NuGet cache), then this image is just one small COPY on top of stock
# Jellyfin, so it builds in seconds.
#
# ./test-image.sh # builds plugin + image, runs it
#
# or manually:
# dotnet publish Jellyfin.Plugin.SRFPlay/Jellyfin.Plugin.SRFPlay.csproj \
# -c Release -f net10.0 --no-self-contained -o ./.test-plugin/SRFPlay
# docker build -f Dockerfile.test --build-arg JELLYFIN_TAG=12 -t srfplay-test .
# docker run --rm -p 8096:8096 srfplay-test
#
# JELLYFIN_TAG must match the framework the plugin was published for;
# test-image.sh picks both from one setting.
ARG JELLYFIN_TAG=12
FROM jellyfin/jellyfin:${JELLYFIN_TAG}
# .test-plugin/SRFPlay is produced by test-image.sh and contains the plugin
# DLL plus a generated meta.json. Jellyfin loads from <config>/plugins.
COPY .test-plugin/SRFPlay /config/plugins/SRFPlay
@@ -5,13 +5,13 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.1" /> <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.1" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework> <!-- Mirrors the plugin: jprm publishes the whole solution for one framework at a time. -->
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
@@ -257,7 +257,7 @@
fetch(ApiClient.serverAddress() + '/Plugins/SRFPlay/Maintenance/ResumePoints/Cleanup?clearAll=' + (clearAll ? 'true' : 'false'), { fetch(ApiClient.serverAddress() + '/Plugins/SRFPlay/Maintenance/ResumePoints/Cleanup?clearAll=' + (clearAll ? 'true' : 'false'), {
method: 'POST', method: 'POST',
headers: { 'X-Emby-Token': ApiClient.accessToken() } headers: { 'Authorization': 'MediaBrowser Token="' + ApiClient.accessToken() + '"' }
}) })
.then(function(response) { .then(function(response) {
if (!response.ok) { if (!response.ok) {
@@ -285,7 +285,7 @@
getHeaders: function() { getHeaders: function() {
return { return {
'X-Emby-Token': ApiClient.accessToken() 'Authorization': 'MediaBrowser Token="' + ApiClient.accessToken() + '"'
}; };
}, },
@@ -92,7 +92,7 @@
verifySession: function(session) { verifySession: function(session) {
var self = this; var self = this;
fetch(this.serverUrl + '/System/Info', { fetch(this.serverUrl + '/System/Info', {
headers: { 'X-Emby-Token': this.token } headers: { 'Authorization': 'MediaBrowser Token="' + this.token + '"' }
}).then(function(r) { }).then(function(r) {
if (r.ok) { if (r.ok) {
document.getElementById('srfrecUserInfo').textContent = '(Server: ' + self.serverUrl + ')'; document.getElementById('srfrecUserInfo').textContent = '(Server: ' + self.serverUrl + ')';
@@ -119,7 +119,7 @@
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Emby-Authorization': 'MediaBrowser Client="SRF Recordings", Device="Web", DeviceId="srfrec-' + Date.now() + '", Version="1.0.0"' 'Authorization': 'MediaBrowser Client="SRF Recordings", Device="Web", DeviceId="srfrec-' + Date.now() + '", Version="1.0.0"'
}, },
body: JSON.stringify({ Username: user, Pw: pass }) body: JSON.stringify({ Username: user, Pw: pass })
}) })
@@ -151,7 +151,7 @@
}, },
getHeaders: function() { getHeaders: function() {
return { 'X-Emby-Token': this.token }; return { 'Authorization': 'MediaBrowser Token="' + this.token + '"' };
}, },
formatDate: function(dateStr) { formatDate: function(dateStr) {
@@ -1,7 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <!--
One DLL per Jellyfin generation. The server binds to the Jellyfin API it was built
against, and 10.11 changed signatures (User moved, TaskTriggerInfo.Type became an enum,
GetItemList return type) that still compile from source but fail at runtime, so a single
net8.0 build silently half-works on newer servers. Each TFM pins the oldest package of its
line so it loads on every patch release of that line:
net8.0 -> Jellyfin 10.9 / 10.10 (targetAbi 10.9.0.0)
net9.0 -> Jellyfin 10.11 (targetAbi 10.11.0.0)
net10.0 -> Jellyfin 12 (targetAbi 12.0.0.0)
-->
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
<RootNamespace>Jellyfin.Plugin.SRFPlay</RootNamespace> <RootNamespace>Jellyfin.Plugin.SRFPlay</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
@@ -10,9 +20,30 @@
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <PropertyGroup Condition="'$(TargetFramework)' != 'net8.0'">
<DefineConstants>$(DefineConstants);JELLYFIN_10_11_OR_GREATER</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$(TargetFramework)' == 'net10.0'">
<DefineConstants>$(DefineConstants);JELLYFIN_12_OR_GREATER</DefineConstants>
</PropertyGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<PackageReference Include="Jellyfin.Controller" Version="10.9.11" /> <PackageReference Include="Jellyfin.Controller" Version="10.9.11" />
<PackageReference Include="Jellyfin.Model" Version="10.9.11" /> <PackageReference Include="Jellyfin.Model" Version="10.9.11" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net9.0'">
<PackageReference Include="Jellyfin.Controller" Version="10.11.0" />
<PackageReference Include="Jellyfin.Model" Version="10.11.0" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<PackageReference Include="Jellyfin.Controller" Version="12.0.0" />
<PackageReference Include="Jellyfin.Model" Version="12.0.0" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Socks5" Version="1.1.0" /> <PackageReference Include="Socks5" Version="1.1.0" />
<PackageReference Include="SkiaSharp" Version="2.88.8" /> <PackageReference Include="SkiaSharp" Version="2.88.8" />
</ItemGroup> </ItemGroup>
@@ -104,11 +104,7 @@ public class ContentRefreshTask : IScheduledTask
// Run every X hours as configured // Run every X hours as configured
return new[] return new[]
{ {
new TaskTriggerInfo TaskTriggers.Interval(TimeSpan.FromHours(intervalHours))
{
Type = TaskTriggerInfo.TriggerInterval,
IntervalTicks = TimeSpan.FromHours(intervalHours).Ticks
}
}; };
} }
} }
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Utilities;
using MediaBrowser.Model.Tasks; using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -87,16 +88,8 @@ public class ExpirationCheckTask : IScheduledTask
// Run daily at 3 AM or every X hours as configured // Run daily at 3 AM or every X hours as configured
return new[] return new[]
{ {
new TaskTriggerInfo TaskTriggers.Daily(TimeSpan.FromHours(3)),
{ TaskTriggers.Interval(TimeSpan.FromHours(intervalHours))
Type = TaskTriggerInfo.TriggerDaily,
TimeOfDayTicks = TimeSpan.FromHours(3).Ticks
},
new TaskTriggerInfo
{
Type = TaskTriggerInfo.TriggerInterval,
IntervalTicks = TimeSpan.FromHours(intervalHours).Ticks
}
}; };
} }
} }
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Utilities;
using MediaBrowser.Model.Tasks; using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -66,11 +67,7 @@ public class RecordingSchedulerTask : IScheduledTask
{ {
return new[] return new[]
{ {
new TaskTriggerInfo TaskTriggers.Interval(TimeSpan.FromSeconds(30))
{
Type = TaskTriggerInfo.TriggerInterval,
IntervalTicks = TimeSpan.FromSeconds(30).Ticks
}
}; };
} }
} }
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Utilities;
using MediaBrowser.Model.Tasks; using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -77,11 +78,7 @@ public class ResumeCleanupTask : IScheduledTask
// Runs after the 3 AM expiration check so items deleted there are already gone. // Runs after the 3 AM expiration check so items deleted there are already gone.
return new[] return new[]
{ {
new TaskTriggerInfo TaskTriggers.Daily(TimeSpan.FromHours(4))
{
Type = TaskTriggerInfo.TriggerDaily,
TimeOfDayTicks = TimeSpan.FromHours(4).Ticks
}
}; };
} }
} }
@@ -1,6 +1,10 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading; using System.Threading;
#if JELLYFIN_10_11_OR_GREATER
using Jellyfin.Database.Implementations.Entities;
#else
using Jellyfin.Data.Entities; using Jellyfin.Data.Entities;
#endif
using Jellyfin.Plugin.SRFPlay.Api.Models; using Jellyfin.Plugin.SRFPlay.Api.Models;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
@@ -3,7 +3,11 @@ using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
#if JELLYFIN_10_11_OR_GREATER
using Jellyfin.Database.Implementations.Entities;
#else
using Jellyfin.Data.Entities; using Jellyfin.Data.Entities;
#endif
using Jellyfin.Data.Enums; using Jellyfin.Data.Enums;
using Jellyfin.Plugin.SRFPlay.Api.Models; using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Configuration; using Jellyfin.Plugin.SRFPlay.Configuration;
@@ -71,7 +75,11 @@ public class ResumeCleanupService : IResumeCleanupService
var channelIds = GetPluginChannelIds(); var channelIds = GetPluginChannelIds();
#if JELLYFIN_12_OR_GREATER
foreach (var user in _userManager.GetUsers())
#else
foreach (var user in _userManager.Users) foreach (var user in _userManager.Users)
#endif
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
result.UsersChecked++; result.UsersChecked++;
@@ -0,0 +1,49 @@
using System;
using MediaBrowser.Model.Tasks;
namespace Jellyfin.Plugin.SRFPlay.Utilities;
/// <summary>
/// Builds scheduled task triggers across Jellyfin versions.
/// Jellyfin 10.11 replaced the string constants on <see cref="TaskTriggerInfo"/> with the
/// <c>TaskTriggerInfoType</c> enum. A task whose GetDefaultTriggers throws is silently given a
/// 24h fallback trigger, so this must be built against the matching Jellyfin version.
/// </summary>
public static class TaskTriggers
{
/// <summary>
/// Creates a trigger that fires repeatedly at a fixed interval.
/// </summary>
/// <param name="interval">The interval between runs.</param>
/// <returns>The trigger.</returns>
public static TaskTriggerInfo Interval(TimeSpan interval)
{
return new TaskTriggerInfo
{
#if JELLYFIN_10_11_OR_GREATER
Type = TaskTriggerInfoType.IntervalTrigger,
#else
Type = TaskTriggerInfo.TriggerInterval,
#endif
IntervalTicks = interval.Ticks
};
}
/// <summary>
/// Creates a trigger that fires once a day at the given time.
/// </summary>
/// <param name="timeOfDay">The time of day to run at.</param>
/// <returns>The trigger.</returns>
public static TaskTriggerInfo Daily(TimeSpan timeOfDay)
{
return new TaskTriggerInfo
{
#if JELLYFIN_10_11_OR_GREATER
Type = TaskTriggerInfoType.DailyTrigger,
#else
Type = TaskTriggerInfo.TriggerDaily,
#endif
TimeOfDayTicks = timeOfDay.Ticks
};
}
}
+13 -2
View File
@@ -99,9 +99,20 @@ Examples:
### Prerequisites ### Prerequisites
- .NET 8.0 SDK - .NET 10.0 SDK (builds all three targets below)
- Jellyfin 10.9.11 or later - Jellyfin 10.9.11 or later
The plugin is built once per Jellyfin generation, because 10.11 changed APIs in ways that
compile but fail at runtime. Installing from the repository picks the right one automatically.
| Jellyfin | Target framework | targetAbi |
|---|---|---|
| 10.9, 10.10 | net8.0 | 10.9.0.0 |
| 10.11 | net9.0 | 10.11.0.0 |
| 12 | net10.0 | 12.0.0.0 |
`.gitea/scripts/build-plugins.sh` builds all three zips the way CI does.
### Build Steps ### Build Steps
```bash ```bash
@@ -111,7 +122,7 @@ dotnet build
### Output ### Output
The compiled plugin will be in `bin/Debug/net8.0/` The compiled plugins will be in `bin/Debug/net8.0/`, `bin/Debug/net9.0/` and `bin/Debug/net10.0/`
## Manual Installation ## Manual Installation
+2
View File
@@ -100,6 +100,8 @@
<Rule Id="CA1308" Action="None" /> <Rule Id="CA1308" Action="None" />
<!-- disable warning CA1848: Use the LoggerMessage delegates --> <!-- disable warning CA1848: Use the LoggerMessage delegates -->
<Rule Id="CA1848" Action="None" /> <Rule Id="CA1848" Action="None" />
<!-- disable warning CA1873: Avoid potentially expensive logging (new in the .NET 10 analyzers; same family as CA1848) -->
<Rule Id="CA1873" Action="None" />
<!-- disable warning CA2101: Specify marshaling for P/Invoke string arguments --> <!-- disable warning CA2101: Specify marshaling for P/Invoke string arguments -->
<Rule Id="CA2101" Action="None" /> <Rule Id="CA2101" Action="None" />
<!-- disable warning CA2234: Pass System.Uri objects instead of strings --> <!-- disable warning CA2234: Pass System.Uri objects instead of strings -->
+8
View File
@@ -8,6 +8,14 @@
"category": "Live TV", "category": "Live TV",
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png", "imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
"versions": [ "versions": [
{
"version": "1.0.20260908.201",
"changelog": "Nightly build 1.0.20260908.201 (d0d5d06)",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260908.201.zip",
"checksum": "e1c9ed45efbbe7c889f2158fe7c1c537",
"timestamp": "2026-09-08T17:28:03Z"
},
{ {
"version": "1.0.20260903.198", "version": "1.0.20260903.198",
"changelog": "Nightly build 1.0.20260903.198 (699b543)", "changelog": "Nightly build 1.0.20260903.198 (699b543)",
+8
View File
@@ -8,6 +8,14 @@
"category": "Live TV", "category": "Live TV",
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png", "imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
"versions": [ "versions": [
{
"version": "1.1.2",
"changelog": "Release 1.1.2",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.1.2/srfplay_1.1.2.0.zip",
"checksum": "09b96308e9796bf25d681da29d10bfed",
"timestamp": "2026-09-08T17:31:00Z"
},
{ {
"version": "1.1.1", "version": "1.1.1",
"changelog": "Release 1.1.1", "changelog": "Release 1.1.1",
Executable
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# Build the SRF Play plugin from the local checkout, bake it into a stock
# Jellyfin image, and run it on http://localhost:8096.
#
# ./test-image.sh build + run
# ./test-image.sh build build image only (no run)
#
# JELLYFIN_TAG picks the Jellyfin image (default 12) and, with it, the matching
# plugin build (see the TargetFrameworks comment in the plugin csproj):
#
# JELLYFIN_TAG=10.11.0 ./test-image.sh
set -euo pipefail
cd "$(dirname "$0")"
JELLYFIN_TAG="${JELLYFIN_TAG:-12}"
PLUGIN_DIR=".test-plugin/SRFPlay"
IMAGE="srfplay-test:${JELLYFIN_TAG}"
case "$JELLYFIN_TAG" in
10.9*|10.10*) FRAMEWORK="net8.0"; ABI="10.9.0.0" ;;
10.11*) FRAMEWORK="net9.0"; ABI="10.11.0.0" ;;
*) FRAMEWORK="net10.0"; ABI="12.0.0.0" ;;
esac
echo ">> Publishing plugin for Jellyfin ${JELLYFIN_TAG} (${FRAMEWORK}, host build, warm cache)..."
rm -rf .test-plugin
dotnet publish Jellyfin.Plugin.SRFPlay/Jellyfin.Plugin.SRFPlay.csproj \
--configuration Release --framework "$FRAMEWORK" --no-self-contained -o /tmp/srfplay-publish >/dev/null
mkdir -p "$PLUGIN_DIR"
cp /tmp/srfplay-publish/Jellyfin.Plugin.SRFPlay.dll "$PLUGIN_DIR/"
# Generate the meta.json Jellyfin needs to load the plugin, from build.yaml.
# targetAbi follows the framework chosen above rather than build.yaml.
GUID=$(grep -E '^guid:' build.yaml | sed -E 's/^guid:[[:space:]]*"?([^"]+)"?.*/\1/')
VERSION=$(grep -E '^version:' build.yaml | sed -E 's/^version:[[:space:]]*"?([^"]+)"?.*/\1/')
cat > "$PLUGIN_DIR/meta.json" <<EOF
{
"guid": "$GUID",
"name": "SRF Play",
"version": "$VERSION",
"targetAbi": "$ABI",
"owner": "dtourolle",
"overview": "SRF Play plugin for Jellyfin (test build)",
"description": "Local test build of the SRF Play plugin.",
"category": "Live TV",
"status": "Active",
"autoUpdate": false
}
EOF
echo ">> Building image $IMAGE (stock Jellyfin + plugin COPY)..."
docker build -f Dockerfile.test --build-arg JELLYFIN_TAG="$JELLYFIN_TAG" -t "$IMAGE" .
if [ "${1:-run}" = "build" ]; then
echo ">> Built $IMAGE. Run with: docker run --rm -p 8096:8096 $IMAGE"
exit 0
fi
# --- Optional: route the container's egress via a LAN gateway ----------------
# Set DOCKER_GATEWAY to a LAN gateway IP (e.g. the Swiss VPN box 192.168.1.52)
# to send this container's outbound traffic through it. Needs sudo once to add
# host policy-route + masquerade; both are removed automatically on exit.
#
# DOCKER_GATEWAY=192.168.1.52 ./test-image.sh
#
# When unset, the container uses Docker's normal default route (your ISP).
TEST_NET="srfplay-testnet"
TEST_SUBNET="172.31.250.0/24"
TEST_TABLE="250" # dedicated rt table for this subnet
# The uplink is the interface that actually reaches the gateway (the LAN NIC,
# e.g. wlan0) — NOT the host default route, which a VPN killswitch may point
# at the tunnel. Derive it from the route to DOCKER_GATEWAY when set.
if [ -n "${DOCKER_GATEWAY:-}" ]; then
UPLINK_IF="$(ip route get "$DOCKER_GATEWAY" 2>/dev/null | grep -oE 'dev [^ ]+' | awk '{print $2}')"
fi
cleanup() {
echo ">> Cleaning up..."
docker rm -f srfplay-test-run >/dev/null 2>&1 || true
if [ -n "${DOCKER_GATEWAY:-}" ]; then
sudo ip rule del from "$TEST_SUBNET" table "$TEST_TABLE" 2>/dev/null || true
sudo ip route flush table "$TEST_TABLE" 2>/dev/null || true
sudo iptables -t nat -D POSTROUTING -s "$TEST_SUBNET" -o "$UPLINK_IF" -j MASQUERADE 2>/dev/null || true
fi
docker network rm "$TEST_NET" >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
docker network rm "$TEST_NET" >/dev/null 2>&1 || true
docker network create --subnet "$TEST_SUBNET" "$TEST_NET" >/dev/null
if [ -n "${DOCKER_GATEWAY:-}" ]; then
echo ">> Routing container egress via gateway $DOCKER_GATEWAY (uplink $UPLINK_IF)..."
echo " (sudo needed once to install host routes; removed on exit)"
sudo ip route replace default via "$DOCKER_GATEWAY" dev "$UPLINK_IF" table "$TEST_TABLE"
sudo ip rule add from "$TEST_SUBNET" table "$TEST_TABLE" 2>/dev/null || true
sudo iptables -t nat -C POSTROUTING -s "$TEST_SUBNET" -o "$UPLINK_IF" -j MASQUERADE 2>/dev/null \
|| sudo iptables -t nat -A POSTROUTING -s "$TEST_SUBNET" -o "$UPLINK_IF" -j MASQUERADE
fi
echo ">> Starting Jellyfin on http://localhost:8096 (Ctrl-C to stop)..."
docker run --rm --name srfplay-test-run --network "$TEST_NET" -p 8096:8096 "$IMAGE"