Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db9461cf59 | ||
|
|
b0c067ed41 | ||
|
|
582c94f5f9 | ||
|
|
dc2dd09e49 | ||
|
|
3cbbd20e71 | ||
|
|
d0d5d06a5e | ||
|
|
97b644a3b4 | ||
|
|
699b5432b9 | ||
|
|
7e1973aca7 | ||
|
|
47bf506dc6 | ||
|
|
390146e8d4 | ||
|
|
a9db0aa09c | ||
|
|
7c81ef94ca | ||
|
|
f715130302 |
Executable
+62
@@ -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
|
||||
@@ -51,14 +51,10 @@ jobs:
|
||||
LABEL="master"
|
||||
fi
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "format=${VERSION}%02d" >> $GITHUB_OUTPUT
|
||||
echo "label=${LABEL}" >> $GITHUB_OUTPUT
|
||||
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
|
||||
working-directory: build-${{ github.run_id }}
|
||||
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 }}
|
||||
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal
|
||||
|
||||
- name: Build Jellyfin Plugin
|
||||
id: jprm
|
||||
- name: Build Jellyfin Plugins
|
||||
working-directory: build-${{ github.run_id }}
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
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}"
|
||||
# One zip per Jellyfin generation (see .gitea/scripts/build-plugins.sh).
|
||||
run: .gitea/scripts/build-plugins.sh "${{ steps.version.outputs.format }}"
|
||||
|
||||
- name: Upload build artifact
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
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
|
||||
if-no-files-found: error
|
||||
|
||||
|
||||
@@ -27,18 +27,15 @@ jobs:
|
||||
# Date-based nightly version: 1.0.<YYYYMMDD>.<run_number>
|
||||
# The run_number suffix keeps multiple builds on the same day
|
||||
# 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")
|
||||
VERSION="1.0.${DATE}.${{ github.run_number }}"
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "format=${VERSION}%02d" >> $GITHUB_OUTPUT
|
||||
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
|
||||
working-directory: build-${{ github.run_id }}
|
||||
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
|
||||
@@ -51,25 +48,9 @@ jobs:
|
||||
working-directory: build-${{ github.run_id }}
|
||||
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal
|
||||
|
||||
- name: Build Jellyfin Plugin
|
||||
id: jprm
|
||||
- name: Build Jellyfin Plugins
|
||||
working-directory: build-${{ github.run_id }}
|
||||
run: |
|
||||
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}"
|
||||
run: .gitea/scripts/build-plugins.sh "${{ steps.version.outputs.format }}"
|
||||
|
||||
- name: Delete existing nightly release
|
||||
working-directory: build-${{ github.run_id }}
|
||||
@@ -129,13 +110,15 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Upload plugin artifact
|
||||
echo "Uploading plugin artifact..."
|
||||
curl -f -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/zip" \
|
||||
--data-binary "@${{ steps.jprm.outputs.artifact }}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=${{ steps.jprm.outputs.artifact_name }}"
|
||||
# Upload one plugin zip per Jellyfin generation
|
||||
cut -f3 artifacts/builds.tsv | while read -r ZIP; do
|
||||
echo "Uploading ${ZIP}..."
|
||||
curl -f -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/zip" \
|
||||
--data-binary "@${ZIP}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=$(basename "${ZIP}")"
|
||||
done
|
||||
|
||||
# Upload build.yaml
|
||||
echo "Uploading build.yaml..."
|
||||
@@ -155,37 +138,45 @@ jobs:
|
||||
REPO_OWNER="${{ github.repository_owner }}"
|
||||
REPO_NAME="${{ github.event.repository.name }}"
|
||||
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")
|
||||
DOWNLOAD_URL="${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/latest/${ARTIFACT_NAME}"
|
||||
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.email "actions@gitea.tourolle.paris"
|
||||
git fetch origin master
|
||||
git checkout master
|
||||
# -B against origin/master, not a bare `checkout master`. The bare
|
||||
# form switches to the LOCAL master that actions/checkout left at the
|
||||
# run's own SHA, so if master moved while the build ran -- and this is
|
||||
# a nightly, so it usually has -- the manifest is edited on a stale
|
||||
# base and the push is rejected non-fast-forward. Run 1045 failed
|
||||
# exactly this way, having already printed "Your branch is behind
|
||||
# 'origin/master' by 1 commit" one line before committing anyway.
|
||||
#
|
||||
# Resetting is right rather than merely convenient: this step appends
|
||||
# one entry to the manifest as it stands on master, so the base it
|
||||
# edits must be master, not whatever this build started from.
|
||||
git checkout -B master origin/master
|
||||
|
||||
NEW_VERSION=$(cat <<EOF
|
||||
{
|
||||
"version": "${VERSION}",
|
||||
"changelog": "Nightly build ${VERSION} (${SHORT_SHA})",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "${DOWNLOAD_URL}",
|
||||
"checksum": "${CHECKSUM}",
|
||||
"timestamp": "${TIMESTAMP}"
|
||||
}
|
||||
EOF
|
||||
)
|
||||
# One entry per Jellyfin generation; each server installs the newest
|
||||
# entry whose targetAbi it satisfies.
|
||||
NEW_VERSIONS=$(echo "${BUILDS}" | jq -R -s \
|
||||
--arg url "${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/latest" \
|
||||
--arg sha "${SHORT_SHA}" --arg ts "${TIMESTAMP}" \
|
||||
'split("\n") | map(select(length > 0) | split("\t")
|
||||
| {version: .[0], changelog: "Nightly build \(.[0]) (\($sha))", targetAbi: .[1],
|
||||
sourceUrl: "\($url)/\(.[2] | split("/") | last)", checksum: .[3], timestamp: $ts})
|
||||
| reverse')
|
||||
|
||||
# Prepend the new build and keep only the most recent 5 nightlies.
|
||||
jq --argjson newver "${NEW_VERSION}" \
|
||||
'.[0].versions = ([$newver] + .[0].versions)[0:5]' \
|
||||
# Prepend the new builds and keep only the most recent 5 nightlies
|
||||
# (3 entries each, one per Jellyfin generation).
|
||||
jq --argjson newvers "${NEW_VERSIONS}" \
|
||||
'.[0].versions = ($newvers + .[0].versions)[0:15]' \
|
||||
manifest-nightly.json > manifest.tmp && mv manifest.tmp 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
|
||||
|
||||
- name: Cleanup
|
||||
|
||||
@@ -35,13 +35,6 @@ jobs:
|
||||
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
|
||||
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
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
@@ -61,25 +54,11 @@ jobs:
|
||||
working-directory: release-${{ github.run_id }}
|
||||
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal
|
||||
|
||||
- name: Build Jellyfin Plugin
|
||||
id: jprm
|
||||
- name: Build Jellyfin Plugins
|
||||
working-directory: release-${{ github.run_id }}
|
||||
run: |
|
||||
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: release-${{ github.run_id }}
|
||||
run: |
|
||||
CHECKSUM=$(md5sum "${{ steps.jprm.outputs.artifact }}" | awk '{print $1}')
|
||||
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
|
||||
echo "Checksum: ${CHECKSUM}"
|
||||
# v1.2.0 -> 1.2.0.9 / 1.2.0.11 / 1.2.0.12, one per Jellyfin generation
|
||||
# (see .gitea/scripts/build-plugins.sh).
|
||||
run: .gitea/scripts/build-plugins.sh "${{ steps.get_version.outputs.version_number }}.%d"
|
||||
|
||||
- name: Create Release
|
||||
working-directory: release-${{ github.run_id }}
|
||||
@@ -111,13 +90,15 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Upload plugin artifact
|
||||
echo "Uploading plugin artifact..."
|
||||
curl -f -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/zip" \
|
||||
--data-binary "@${{ steps.jprm.outputs.artifact }}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=${{ steps.jprm.outputs.artifact_name }}"
|
||||
# Upload one plugin zip per Jellyfin generation
|
||||
cut -f3 artifacts/builds.tsv | while read -r ZIP; do
|
||||
echo "Uploading ${ZIP}..."
|
||||
curl -f -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/zip" \
|
||||
--data-binary "@${ZIP}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=$(basename "${ZIP}")"
|
||||
done
|
||||
|
||||
# Upload build.yaml
|
||||
echo "Uploading build.yaml..."
|
||||
@@ -139,29 +120,28 @@ jobs:
|
||||
REPO_NAME="${{ github.event.repository.name }}"
|
||||
GITEA_URL="${{ github.server_url }}"
|
||||
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")
|
||||
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.email "actions@gitea.tourolle.paris"
|
||||
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
|
||||
{
|
||||
"version": "${VERSION}",
|
||||
"changelog": "Release ${VERSION}",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "${DOWNLOAD_URL}",
|
||||
"checksum": "${CHECKSUM}",
|
||||
"timestamp": "${TIMESTAMP}"
|
||||
}
|
||||
EOF
|
||||
)
|
||||
# One entry per Jellyfin generation; each server installs the newest
|
||||
# entry whose targetAbi it satisfies.
|
||||
NEW_VERSIONS=$(echo "${BUILDS}" | jq -R -s \
|
||||
--arg url "${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/${{ steps.get_version.outputs.version }}" \
|
||||
--arg release "${VERSION}" --arg ts "${TIMESTAMP}" \
|
||||
'split("\n") | map(select(length > 0) | split("\t")
|
||||
| {version: .[0], changelog: "Release \($release)", targetAbi: .[1],
|
||||
sourceUrl: "\($url)/\(.[2] | split("/") | last)", checksum: .[3], timestamp: $ts})
|
||||
| reverse')
|
||||
|
||||
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 commit -m "Update manifest.json for version ${VERSION}"
|
||||
git push origin master
|
||||
|
||||
@@ -4,3 +4,4 @@ obj/
|
||||
.idea/
|
||||
artifacts
|
||||
*.log
|
||||
.test-plugin/
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
# Build: docker build -f Dockerfile.builder -t gitea.tourolle.paris/dtourolle/srfplay-builder:latest .
|
||||
# Push: docker push gitea.tourolle.paris/dtourolle/srfplay-builder:latest
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3 \
|
||||
|
||||
@@ -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>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<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>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of a resume point ("Continue Watching") cleanup run.
|
||||
/// </summary>
|
||||
public class ResumeCleanupResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the number of users whose resume list was inspected.
|
||||
/// </summary>
|
||||
public int UsersChecked { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of plugin-owned resume points that were inspected.
|
||||
/// </summary>
|
||||
public int Inspected { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of resume points that were cleared.
|
||||
/// </summary>
|
||||
public int Cleared { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of cleared resume points grouped by the rule that matched.
|
||||
/// </summary>
|
||||
public Dictionary<string, int> ClearedByReason { get; } = new Dictionary<string, int>();
|
||||
}
|
||||
@@ -75,6 +75,12 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
EnableCategoryFolders = true;
|
||||
EnabledTopics = new System.Collections.Generic.List<string>();
|
||||
GenerateTitleCards = true;
|
||||
LiveStartSegmentsBack = 3;
|
||||
CleanUpResumePoints = true;
|
||||
ClearLiveStreamResumePoints = true;
|
||||
ResumePointMaxAgeDays = 30;
|
||||
ResumePointMinPositionSeconds = 60;
|
||||
ResumePointCompletedPercent = 92;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -162,4 +168,47 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
/// Gets or sets the output directory for sport livestream recordings.
|
||||
/// </summary>
|
||||
public string RecordingOutputPath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets how many segments back from the live edge livestream playback should start.
|
||||
/// Injected as <c>#EXT-X-START:TIME-OFFSET=-(N * targetDuration)</c> into the live media
|
||||
/// playlist. This keeps the start point out of the volatile live edge, which on Android TV
|
||||
/// (ExoPlayer) otherwise causes stalling/jumping until a manual skip. RFC 8216 requires the
|
||||
/// offset to stay at least 3 target durations from the edge, so values below 3 are clamped.
|
||||
/// Set to 0 to disable injection entirely.
|
||||
/// </summary>
|
||||
public int LiveStartSegmentsBack { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the "Clean Up SRF Play Continue Watching" task
|
||||
/// removes stale resume points. When false the task inspects nothing and clears nothing.
|
||||
/// </summary>
|
||||
public bool CleanUpResumePoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether resume points for livestreams are cleared.
|
||||
/// A livestream has no meaningful resume position, so stopping one otherwise leaves it
|
||||
/// pinned in "Continue Watching" forever. When enabled the position is also cleared
|
||||
/// immediately on playback stop, not just by the scheduled task.
|
||||
/// </summary>
|
||||
public bool ClearLiveStreamResumePoints { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the age in days after which an untouched resume point is cleared.
|
||||
/// Measured from the last time the item was played. Set to 0 to disable the age rule.
|
||||
/// </summary>
|
||||
public int ResumePointMaxAgeDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum resume position in seconds. Anything at or below this is
|
||||
/// treated as an accidental start rather than something worth resuming.
|
||||
/// Set to 0 to disable the rule.
|
||||
/// </summary>
|
||||
public int ResumePointMinPositionSeconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the percentage of runtime at or beyond which playback counts as finished.
|
||||
/// Only applied to items with a known runtime. Set to 0 to disable the rule.
|
||||
/// </summary>
|
||||
public int ResumePointCompletedPercent { get; set; }
|
||||
}
|
||||
|
||||
@@ -82,6 +82,47 @@
|
||||
<input id="RecordingOutputPath" name="RecordingOutputPath" type="text" is="emby-input" placeholder="e.g., /media/recordings/srf" />
|
||||
<div class="fieldDescription">Directory where sport livestream recordings will be saved (requires ffmpeg)</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="LiveStartSegmentsBack">Live Start Offset (segments)</label>
|
||||
<input id="LiveStartSegmentsBack" name="LiveStartSegmentsBack" type="number" is="emby-input" min="0" max="20" />
|
||||
<div class="fieldDescription">How many segments back from the live edge livestreams start playing. Fixes jumpy/stalling playback at the start on Android TV. Minimum 3 (RFC requirement); 0 disables it. Default 3.</div>
|
||||
</div>
|
||||
<br />
|
||||
<h2>Continue Watching Cleanup</h2>
|
||||
<div class="fieldDescription" style="margin-bottom: 1em;">
|
||||
Livestreams and abandoned playback otherwise stay pinned in every user's
|
||||
"Continue Watching" row forever. Clearing a resume point only resets the
|
||||
playback position — nothing is deleted and the item stays unwatched.
|
||||
</div>
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
<input id="CleanUpResumePoints" name="CleanUpResumePoints" type="checkbox" is="emby-checkbox" />
|
||||
<span>Clean up stale resume points</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">Runs daily at 4 AM (Scheduled Tasks → "Clean Up SRF Play Continue Watching")</div>
|
||||
</div>
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
<input id="ClearLiveStreamResumePoints" name="ClearLiveStreamResumePoints" type="checkbox" is="emby-checkbox" />
|
||||
<span>Never keep a resume point for livestreams</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">Clears the position as soon as a livestream stops playing, rather than waiting for the daily task</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="ResumePointMaxAgeDays">Maximum Resume Point Age (days)</label>
|
||||
<input id="ResumePointMaxAgeDays" name="ResumePointMaxAgeDays" type="number" is="emby-input" min="0" max="3650" />
|
||||
<div class="fieldDescription">Clear resume points not touched for this many days. 0 disables the rule. Default 30.</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="ResumePointMinPositionSeconds">Minimum Resume Position (seconds)</label>
|
||||
<input id="ResumePointMinPositionSeconds" name="ResumePointMinPositionSeconds" type="number" is="emby-input" min="0" max="3600" />
|
||||
<div class="fieldDescription">Positions at or below this are treated as an accidental start and cleared. 0 disables the rule. Default 60.</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="ResumePointCompletedPercent">Finished Threshold (%)</label>
|
||||
<input id="ResumePointCompletedPercent" name="ResumePointCompletedPercent" type="number" is="emby-input" min="0" max="100" />
|
||||
<div class="fieldDescription">Playback at or beyond this share of the runtime counts as finished. Only applies when the runtime is known. 0 disables the rule. Default 92.</div>
|
||||
</div>
|
||||
<br />
|
||||
<h2>Network Settings</h2>
|
||||
<div class="inputContainer">
|
||||
@@ -100,6 +141,17 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<br />
|
||||
<h2>Continue Watching Maintenance</h2>
|
||||
<div class="fieldDescription">Run the cleanup right now instead of waiting for the daily task. Only SRF Play items are affected.</div>
|
||||
<div id="resumeCleanupResult" style="margin: 10px 0;"></div>
|
||||
<button is="emby-button" type="button" class="raised emby-button" onclick="SRFPlayMaintenance.cleanupResumePoints(false)" style="margin-bottom: 20px;">
|
||||
<span>Clean Up Stale Entries Now</span>
|
||||
</button>
|
||||
<button is="emby-button" type="button" class="raised emby-button" onclick="SRFPlayMaintenance.cleanupResumePoints(true)" style="margin-bottom: 20px;">
|
||||
<span>Clear All SRF Play Entries</span>
|
||||
</button>
|
||||
|
||||
<br />
|
||||
<h2>Sport Livestream Recordings</h2>
|
||||
|
||||
@@ -148,6 +200,12 @@
|
||||
document.querySelector('#ProxyPassword').value = config.ProxyPassword || '';
|
||||
document.querySelector('#PublicServerUrl').value = config.PublicServerUrl || '';
|
||||
document.querySelector('#RecordingOutputPath').value = config.RecordingOutputPath || '';
|
||||
document.querySelector('#LiveStartSegmentsBack').value = config.LiveStartSegmentsBack != null ? config.LiveStartSegmentsBack : 3;
|
||||
document.querySelector('#CleanUpResumePoints').checked = config.CleanUpResumePoints !== false;
|
||||
document.querySelector('#ClearLiveStreamResumePoints').checked = config.ClearLiveStreamResumePoints !== false;
|
||||
document.querySelector('#ResumePointMaxAgeDays').value = config.ResumePointMaxAgeDays != null ? config.ResumePointMaxAgeDays : 30;
|
||||
document.querySelector('#ResumePointMinPositionSeconds').value = config.ResumePointMinPositionSeconds != null ? config.ResumePointMinPositionSeconds : 60;
|
||||
document.querySelector('#ResumePointCompletedPercent').value = config.ResumePointCompletedPercent != null ? config.ResumePointCompletedPercent : 92;
|
||||
Dashboard.hideLoadingMsg();
|
||||
|
||||
// Load recordings UI
|
||||
@@ -173,6 +231,12 @@
|
||||
config.ProxyPassword = document.querySelector('#ProxyPassword').value;
|
||||
config.PublicServerUrl = document.querySelector('#PublicServerUrl').value;
|
||||
config.RecordingOutputPath = document.querySelector('#RecordingOutputPath').value;
|
||||
config.LiveStartSegmentsBack = parseInt(document.querySelector('#LiveStartSegmentsBack').value) || 0;
|
||||
config.CleanUpResumePoints = document.querySelector('#CleanUpResumePoints').checked;
|
||||
config.ClearLiveStreamResumePoints = document.querySelector('#ClearLiveStreamResumePoints').checked;
|
||||
config.ResumePointMaxAgeDays = parseInt(document.querySelector('#ResumePointMaxAgeDays').value) || 0;
|
||||
config.ResumePointMinPositionSeconds = parseInt(document.querySelector('#ResumePointMinPositionSeconds').value) || 0;
|
||||
config.ResumePointCompletedPercent = parseInt(document.querySelector('#ResumePointCompletedPercent').value) || 0;
|
||||
ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) {
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
});
|
||||
@@ -182,12 +246,46 @@
|
||||
return false;
|
||||
});
|
||||
|
||||
var SRFPlayMaintenance = {
|
||||
cleanupResumePoints: function(clearAll) {
|
||||
if (clearAll && !confirm('Clear the playback position of every SRF Play item for all users? Nothing is deleted and items stay unwatched.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
var target = document.querySelector('#resumeCleanupResult');
|
||||
target.innerHTML = '<p><em>Cleaning up...</em></p>';
|
||||
|
||||
fetch(ApiClient.serverAddress() + '/Plugins/SRFPlay/Maintenance/ResumePoints/Cleanup?clearAll=' + (clearAll ? 'true' : 'false'), {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'MediaBrowser Token="' + ApiClient.accessToken() + '"' }
|
||||
})
|
||||
.then(function(response) {
|
||||
if (!response.ok) {
|
||||
throw new Error('HTTP ' + response.status);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(function(result) {
|
||||
var byReason = result.clearedByReason || {};
|
||||
var reasons = Object.keys(byReason)
|
||||
.map(function(key) { return key + ': ' + byReason[key]; })
|
||||
.join(', ');
|
||||
target.innerHTML = '<p>Cleared <strong>' + result.cleared + '</strong> of ' + result.inspected +
|
||||
' SRF Play resume point(s) across ' + result.usersChecked + ' user(s).' +
|
||||
(reasons ? ' (' + reasons + ')' : '') + '</p>';
|
||||
})
|
||||
.catch(function(error) {
|
||||
target.innerHTML = '<p style="color: #d44;">Cleanup failed: ' + error.message + '</p>';
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
var SRFPlayRecordings = {
|
||||
apiBase: ApiClient.serverAddress() + '/Plugins/SRFPlay/Recording',
|
||||
|
||||
getHeaders: function() {
|
||||
return {
|
||||
'X-Emby-Token': ApiClient.accessToken()
|
||||
'Authorization': 'MediaBrowser Token="' + ApiClient.accessToken() + '"'
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
verifySession: function(session) {
|
||||
var self = this;
|
||||
fetch(this.serverUrl + '/System/Info', {
|
||||
headers: { 'X-Emby-Token': this.token }
|
||||
headers: { 'Authorization': 'MediaBrowser Token="' + this.token + '"' }
|
||||
}).then(function(r) {
|
||||
if (r.ok) {
|
||||
document.getElementById('srfrecUserInfo').textContent = '(Server: ' + self.serverUrl + ')';
|
||||
@@ -119,7 +119,7 @@
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'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 })
|
||||
})
|
||||
@@ -151,7 +151,7 @@
|
||||
},
|
||||
|
||||
getHeaders: function() {
|
||||
return { 'X-Emby-Token': this.token };
|
||||
return { 'Authorization': 'MediaBrowser Token="' + this.token + '"' };
|
||||
},
|
||||
|
||||
formatDate: function(dateStr) {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Threading;
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
using MediaBrowser.Common.Api;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Administrative maintenance actions for the SRF Play plugin.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("Plugins/SRFPlay/Maintenance")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
public class MaintenanceController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<MaintenanceController> _logger;
|
||||
private readonly IResumeCleanupService _resumeCleanupService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MaintenanceController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="resumeCleanupService">The resume cleanup service.</param>
|
||||
public MaintenanceController(
|
||||
ILogger<MaintenanceController> logger,
|
||||
IResumeCleanupService resumeCleanupService)
|
||||
{
|
||||
_logger = logger;
|
||||
_resumeCleanupService = resumeCleanupService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears stale SRF Play resume points from every user's "Continue Watching" row.
|
||||
/// </summary>
|
||||
/// <param name="clearAll">
|
||||
/// When true, clears every SRF Play resume point instead of only the stale ones.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A summary of what was inspected and cleared.</returns>
|
||||
[HttpPost("ResumePoints/Cleanup")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<ResumeCleanupResult> CleanupResumePoints(
|
||||
[FromQuery] bool clearAll,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Manual Continue Watching cleanup requested (clearAll: {ClearAll})", clearAll);
|
||||
return Ok(_resumeCleanupService.Cleanup(clearAll, cancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -225,7 +225,8 @@ public class StreamProxyController : ControllerBase
|
||||
queryParams = string.Empty;
|
||||
}
|
||||
|
||||
var rewrittenContent = _proxyService.RewriteVariantManifestUrls(manifestContent, baseProxyUrl, queryParams);
|
||||
var isLiveStream = _proxyService.GetStreamMetadata(actualItemId)?.IsLiveStream ?? false;
|
||||
var rewrittenContent = _proxyService.RewriteVariantManifestUrls(manifestContent, baseProxyUrl, queryParams, isLiveStream);
|
||||
|
||||
// Set cache headers based on stream type (live vs VOD)
|
||||
// Variant manifests use stricter no-cache for live streams
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<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>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
@@ -10,9 +20,30 @@
|
||||
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
|
||||
</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.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="SkiaSharp" Version="2.88.8" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -104,11 +104,7 @@ public class ContentRefreshTask : IScheduledTask
|
||||
// Run every X hours as configured
|
||||
return new[]
|
||||
{
|
||||
new TaskTriggerInfo
|
||||
{
|
||||
Type = TaskTriggerInfo.TriggerInterval,
|
||||
IntervalTicks = TimeSpan.FromHours(intervalHours).Ticks
|
||||
}
|
||||
TaskTriggers.Interval(TimeSpan.FromHours(intervalHours))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
using Jellyfin.Plugin.SRFPlay.Utilities;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -87,16 +88,8 @@ public class ExpirationCheckTask : IScheduledTask
|
||||
// Run daily at 3 AM or every X hours as configured
|
||||
return new[]
|
||||
{
|
||||
new TaskTriggerInfo
|
||||
{
|
||||
Type = TaskTriggerInfo.TriggerDaily,
|
||||
TimeOfDayTicks = TimeSpan.FromHours(3).Ticks
|
||||
},
|
||||
new TaskTriggerInfo
|
||||
{
|
||||
Type = TaskTriggerInfo.TriggerInterval,
|
||||
IntervalTicks = TimeSpan.FromHours(intervalHours).Ticks
|
||||
}
|
||||
TaskTriggers.Daily(TimeSpan.FromHours(3)),
|
||||
TaskTriggers.Interval(TimeSpan.FromHours(intervalHours))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
using Jellyfin.Plugin.SRFPlay.Utilities;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -66,11 +67,7 @@ public class RecordingSchedulerTask : IScheduledTask
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new TaskTriggerInfo
|
||||
{
|
||||
Type = TaskTriggerInfo.TriggerInterval,
|
||||
IntervalTicks = TimeSpan.FromSeconds(30).Ticks
|
||||
}
|
||||
TaskTriggers.Interval(TimeSpan.FromSeconds(30))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
using Jellyfin.Plugin.SRFPlay.Utilities;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.ScheduledTasks;
|
||||
|
||||
/// <summary>
|
||||
/// Scheduled task that clears stale SRF Play resume points from "Continue Watching".
|
||||
/// </summary>
|
||||
public class ResumeCleanupTask : IScheduledTask
|
||||
{
|
||||
private readonly ILogger<ResumeCleanupTask> _logger;
|
||||
private readonly IResumeCleanupService _resumeCleanupService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ResumeCleanupTask"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="resumeCleanupService">The resume cleanup service.</param>
|
||||
public ResumeCleanupTask(
|
||||
ILogger<ResumeCleanupTask> logger,
|
||||
IResumeCleanupService resumeCleanupService)
|
||||
{
|
||||
_logger = logger;
|
||||
_resumeCleanupService = resumeCleanupService;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Clean Up SRF Play Continue Watching";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Removes stale SRF Play resume points - ended livestreams, abandoned playback and finished programmes - from every user's Continue Watching row";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Category => "SRF Play";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Key => "SRFPlayResumeCleanup";
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Starting SRF Play Continue Watching cleanup");
|
||||
progress?.Report(0);
|
||||
|
||||
try
|
||||
{
|
||||
var result = _resumeCleanupService.Cleanup(false, cancellationToken);
|
||||
|
||||
progress?.Report(100);
|
||||
_logger.LogInformation(
|
||||
"SRF Play Continue Watching cleanup completed. Cleared {Cleared} of {Inspected} resume points",
|
||||
result.Cleared,
|
||||
result.Inspected);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("SRF Play Continue Watching cleanup was cancelled");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during SRF Play Continue Watching cleanup");
|
||||
throw;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||
{
|
||||
// Runs after the 3 AM expiration check so items deleted there are already gone.
|
||||
return new[]
|
||||
{
|
||||
TaskTriggers.Daily(TimeSpan.FromHours(4))
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,11 @@ public class ServiceRegistrator : IPluginServiceRegistrator
|
||||
serviceCollection.AddSingleton<IContentExpirationService, ContentExpirationService>();
|
||||
serviceCollection.AddSingleton<IContentRefreshService, ContentRefreshService>();
|
||||
serviceCollection.AddSingleton<ICategoryService, CategoryService>();
|
||||
serviceCollection.AddSingleton<IResumeCleanupService, ResumeCleanupService>();
|
||||
|
||||
// Clears livestream resume points the moment playback stops, so ended broadcasts never
|
||||
// linger in "Continue Watching" waiting for the nightly cleanup task.
|
||||
serviceCollection.AddHostedService<PlaybackResumeGuard>();
|
||||
|
||||
// Register metadata providers
|
||||
serviceCollection.AddSingleton<SRFSeriesProvider>();
|
||||
@@ -48,6 +53,7 @@ public class ServiceRegistrator : IPluginServiceRegistrator
|
||||
serviceCollection.AddSingleton<IScheduledTask, ContentRefreshTask>();
|
||||
serviceCollection.AddSingleton<IScheduledTask, ExpirationCheckTask>();
|
||||
serviceCollection.AddSingleton<IScheduledTask, RecordingSchedulerTask>();
|
||||
serviceCollection.AddSingleton<IScheduledTask, ResumeCleanupTask>();
|
||||
|
||||
// Register one channel (tile) per SRG business unit. Each must be registered as IChannel
|
||||
// for Jellyfin to discover it. A unit that is not in EnabledBusinessUnits returns no
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
#if JELLYFIN_10_11_OR_GREATER
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
#else
|
||||
using Jellyfin.Data.Entities;
|
||||
#endif
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Removes stale plugin-owned resume points so ended livestreams and abandoned playback
|
||||
/// stop accumulating in every user's "Continue Watching" row.
|
||||
/// </summary>
|
||||
public interface IResumeCleanupService
|
||||
{
|
||||
/// <summary>
|
||||
/// Scans every user's resumable items and clears the resume point of those owned by this
|
||||
/// plugin that match a staleness rule. Items belonging to other plugins or to the regular
|
||||
/// library are never touched.
|
||||
/// </summary>
|
||||
/// <param name="clearAll">
|
||||
/// When true every plugin-owned resume point is cleared regardless of the staleness rules.
|
||||
/// Used by the "Clear all" maintenance action.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A summary of what was inspected and cleared.</returns>
|
||||
ResumeCleanupResult Cleanup(bool clearAll, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Clears the resume point of a single item for the given users.
|
||||
/// </summary>
|
||||
/// <param name="item">The item whose resume point should be reset.</param>
|
||||
/// <param name="users">The users to clear it for.</param>
|
||||
/// <param name="reason">A short reason, logged for traceability.</param>
|
||||
/// <returns>The number of resume points actually cleared.</returns>
|
||||
int ClearResumePoint(BaseItem item, IEnumerable<User> users, string reason);
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether an item belongs to this plugin (an SRG channel item or a recording).
|
||||
/// </summary>
|
||||
/// <param name="item">The item to test.</param>
|
||||
/// <returns>True if the item is owned by this plugin.</returns>
|
||||
bool IsPluginItem(BaseItem item);
|
||||
|
||||
/// <summary>
|
||||
/// Clears the resume point if the item is one of this plugin's livestreams and livestream
|
||||
/// cleanup is enabled. Called right after playback stops so an ended broadcast never reaches
|
||||
/// "Continue Watching" in the first place, instead of waiting for the nightly task.
|
||||
/// </summary>
|
||||
/// <param name="item">The item that just stopped playing.</param>
|
||||
/// <param name="users">The users the playback was attributed to.</param>
|
||||
/// <returns>The number of resume points cleared.</returns>
|
||||
int ClearLiveStreamResumePoint(BaseItem item, IEnumerable<User> users);
|
||||
}
|
||||
@@ -71,8 +71,9 @@ public interface IStreamProxyService
|
||||
/// <param name="manifestContent">The variant manifest content.</param>
|
||||
/// <param name="baseProxyUrl">The base proxy URL (without query params).</param>
|
||||
/// <param name="queryParams">Query parameters to append to rewritten URLs (e.g., "?token=abc").</param>
|
||||
/// <param name="isLiveStream">Whether this is a livestream; if so an EXT-X-START offset is injected.</param>
|
||||
/// <returns>The rewritten manifest content.</returns>
|
||||
string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams);
|
||||
string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams, bool isLiveStream = false);
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up old and expired stream mappings.
|
||||
|
||||
@@ -7,6 +7,7 @@ using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
using Jellyfin.Plugin.SRFPlay.Configuration;
|
||||
using Jellyfin.Plugin.SRFPlay.Constants;
|
||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
using Jellyfin.Plugin.SRFPlay.Utilities;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
@@ -66,7 +67,7 @@ public class MediaSourceFactory : IMediaSourceFactory
|
||||
// Past replays (ValidTo in the past) and upcoming events (ValidFrom in the future)
|
||||
// are treated as VOD to avoid IsInfiniteStream/IgnoreDts flags and FFmpeg -re mode.
|
||||
var isScheduledLivestream = chapter.Type == "SCHEDULED_LIVESTREAM" ||
|
||||
urn.Contains("livestream", StringComparison.OrdinalIgnoreCase);
|
||||
UrnHelper.IsLivestreamUrn(urn);
|
||||
var now = DateTime.UtcNow;
|
||||
var isLiveStream = isScheduledLivestream &&
|
||||
(chapter.ValidFrom == null || chapter.ValidFrom.Value.ToUniversalTime() <= now) &&
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Watches for livestream playback ending and immediately drops the resume point Jellyfin
|
||||
/// just saved for it. Without this, every livestream a user stops watching sticks in
|
||||
/// "Continue Watching" until the nightly cleanup task runs.
|
||||
/// </summary>
|
||||
public class PlaybackResumeGuard : IHostedService
|
||||
{
|
||||
private readonly ILogger<PlaybackResumeGuard> _logger;
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private readonly IResumeCleanupService _resumeCleanupService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PlaybackResumeGuard"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="sessionManager">The session manager raising playback events.</param>
|
||||
/// <param name="resumeCleanupService">The resume cleanup service.</param>
|
||||
public PlaybackResumeGuard(
|
||||
ILogger<PlaybackResumeGuard> logger,
|
||||
ISessionManager sessionManager,
|
||||
IResumeCleanupService resumeCleanupService)
|
||||
{
|
||||
_logger = logger;
|
||||
_sessionManager = sessionManager;
|
||||
_resumeCleanupService = resumeCleanupService;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_sessionManager.PlaybackStopped += OnPlaybackStopped;
|
||||
_logger.LogDebug("PlaybackResumeGuard attached to session manager");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_sessionManager.PlaybackStopped -= OnPlaybackStopped;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void OnPlaybackStopped(object? sender, PlaybackStopEventArgs e)
|
||||
{
|
||||
// Jellyfin saves the resume point before raising this event, so clearing it here wins.
|
||||
if (e?.Item == null || e.Users == null || e.Users.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var cleared = _resumeCleanupService.ClearLiveStreamResumePoint(e.Item, e.Users);
|
||||
if (cleared > 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Dropped {Count} resume point(s) for finished livestream '{Name}'",
|
||||
cleared,
|
||||
e.Item.Name);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Never let a cleanup failure escape into Jellyfin's playback event pipeline.
|
||||
_logger.LogError(ex, "Error clearing livestream resume point for '{Name}'", e.Item.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ using Jellyfin.Plugin.SRFPlay.Api;
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
|
||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
using Jellyfin.Plugin.SRFPlay.Utilities;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -442,7 +443,7 @@ public class RecordingService : IRecordingService, IDisposable
|
||||
|
||||
// Register the stream with the proxy so we can use the proxy URL
|
||||
var itemId = $"rec_{entry.Id}";
|
||||
var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" || entry.Urn.Contains("livestream", StringComparison.OrdinalIgnoreCase);
|
||||
var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" || UrnHelper.IsLivestreamUrn(entry.Urn);
|
||||
_proxyService.RegisterStreamDeferred(itemId, streamUrl, entry.Urn, isLiveStream);
|
||||
|
||||
// Build proxy URL for ffmpeg (use localhost for local access)
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
#if JELLYFIN_10_11_OR_GREATER
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
#else
|
||||
using Jellyfin.Data.Entities;
|
||||
#endif
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
using Jellyfin.Plugin.SRFPlay.Configuration;
|
||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
using Jellyfin.Plugin.SRFPlay.Utilities;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Removes stale plugin-owned resume points from every user's "Continue Watching" row.
|
||||
/// </summary>
|
||||
public class ResumeCleanupService : IResumeCleanupService
|
||||
{
|
||||
/// <summary>
|
||||
/// Provider ID key stamped onto every item built from the SRG API.
|
||||
/// </summary>
|
||||
private const string SrfProviderId = "SRF";
|
||||
|
||||
private readonly ILogger<ResumeCleanupService> _logger;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IUserDataManager _userDataManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ResumeCleanupService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">The logger factory.</param>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="userManager">The user manager.</param>
|
||||
/// <param name="userDataManager">The user data manager.</param>
|
||||
public ResumeCleanupService(
|
||||
ILoggerFactory loggerFactory,
|
||||
ILibraryManager libraryManager,
|
||||
IUserManager userManager,
|
||||
IUserDataManager userDataManager)
|
||||
{
|
||||
_logger = loggerFactory.CreateLogger<ResumeCleanupService>();
|
||||
_libraryManager = libraryManager;
|
||||
_userManager = userManager;
|
||||
_userDataManager = userDataManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ResumeCleanupResult Cleanup(bool clearAll, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new ResumeCleanupResult();
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
|
||||
if (config == null)
|
||||
{
|
||||
_logger.LogWarning("Plugin configuration not available - skipping resume point cleanup");
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!clearAll && !config.CleanUpResumePoints)
|
||||
{
|
||||
_logger.LogDebug("Resume point cleanup is disabled in configuration");
|
||||
return result;
|
||||
}
|
||||
|
||||
var channelIds = GetPluginChannelIds();
|
||||
|
||||
#if JELLYFIN_12_OR_GREATER
|
||||
foreach (var user in _userManager.GetUsers())
|
||||
#else
|
||||
foreach (var user in _userManager.Users)
|
||||
#endif
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
result.UsersChecked++;
|
||||
|
||||
var query = new InternalItemsQuery(user)
|
||||
{
|
||||
IsResumable = true,
|
||||
Recursive = true,
|
||||
EnableTotalRecordCount = false,
|
||||
DtoOptions = new DtoOptions(false)
|
||||
};
|
||||
|
||||
foreach (var item in _libraryManager.GetItemList(query))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!IsPluginItem(item, channelIds))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Inspected++;
|
||||
|
||||
var userData = _userDataManager.GetUserData(user, item);
|
||||
if (userData == null || userData.PlaybackPositionTicks <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var reason = clearAll ? "clear all" : GetStaleReason(item, userData, config);
|
||||
if (reason == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ClearResumePoint(item, user, userData, reason))
|
||||
{
|
||||
result.Cleared++;
|
||||
result.ClearedByReason.TryGetValue(reason, out var count);
|
||||
result.ClearedByReason[reason] = count + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Resume point cleanup finished: {Cleared} of {Inspected} SRF Play resume points cleared across {Users} user(s)",
|
||||
result.Cleared,
|
||||
result.Inspected,
|
||||
result.UsersChecked);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int ClearResumePoint(BaseItem item, IEnumerable<User> users, string reason)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
ArgumentNullException.ThrowIfNull(users);
|
||||
|
||||
var cleared = 0;
|
||||
|
||||
foreach (var user in users)
|
||||
{
|
||||
var userData = _userDataManager.GetUserData(user, item);
|
||||
if (userData == null || userData.PlaybackPositionTicks <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ClearResumePoint(item, user, userData, reason))
|
||||
{
|
||||
cleared++;
|
||||
}
|
||||
}
|
||||
|
||||
return cleared;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsPluginItem(BaseItem item)
|
||||
{
|
||||
return IsPluginItem(item, GetPluginChannelIds());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int ClearLiveStreamResumePoint(BaseItem item, IEnumerable<User> users)
|
||||
{
|
||||
if (item == null || Plugin.Instance?.Configuration.ClearLiveStreamResumePoints != true)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Only the URN heuristic here - the item is already known, and re-querying the channel
|
||||
// list on every playback stop would be wasteful.
|
||||
if (!UrnHelper.IsLivestreamUrn(item.ProviderIds.GetValueOrDefault(SrfProviderId)))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return ClearResumePoint(item, users, "livestream stopped");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether an item belongs to one of this plugin's channels.
|
||||
/// </summary>
|
||||
/// <param name="item">The item to test.</param>
|
||||
/// <param name="channelIds">The set of channel IDs owned by this plugin.</param>
|
||||
/// <returns>True if the item is owned by this plugin.</returns>
|
||||
private static bool IsPluginItem(BaseItem item, HashSet<Guid> channelIds)
|
||||
{
|
||||
if (item == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// API-sourced content carries the URN as a provider ID. Recordings do not, so fall back
|
||||
// to the owning channel, which also covers any item shape added later.
|
||||
return item.ProviderIds.ContainsKey(SrfProviderId)
|
||||
|| (!item.ChannelId.Equals(Guid.Empty) && channelIds.Contains(item.ChannelId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the IDs of the channel entries created by this plugin, one per business unit.
|
||||
/// </summary>
|
||||
/// <returns>The set of channel IDs, empty if the channels have not been scanned yet.</returns>
|
||||
private HashSet<Guid> GetPluginChannelIds()
|
||||
{
|
||||
// SrgChannelBase names every channel "{unit} Play".
|
||||
var names = Enum.GetNames<BusinessUnit>()
|
||||
.Select(unit => unit + " Play")
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var channels = _libraryManager.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes = new[] { BaseItemKind.Channel },
|
||||
EnableTotalRecordCount = false,
|
||||
DtoOptions = new DtoOptions(false)
|
||||
});
|
||||
|
||||
return channels
|
||||
.Where(channel => channel.Name != null && names.Contains(channel.Name))
|
||||
.Select(channel => channel.Id)
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decides whether a resume point is stale, and why.
|
||||
/// </summary>
|
||||
/// <param name="item">The item the resume point belongs to.</param>
|
||||
/// <param name="userData">The user data holding the resume position.</param>
|
||||
/// <param name="config">The plugin configuration supplying the thresholds.</param>
|
||||
/// <returns>A short reason string, or null when the resume point should be kept.</returns>
|
||||
private static string? GetStaleReason(BaseItem item, UserItemData userData, PluginConfiguration config)
|
||||
{
|
||||
// A livestream has no meaningful position to return to: by the time the user comes back
|
||||
// the broadcast has moved on or ended. These are the entries that pile up the fastest.
|
||||
if (config.ClearLiveStreamResumePoints
|
||||
&& UrnHelper.IsLivestreamUrn(item.ProviderIds.GetValueOrDefault(SrfProviderId)))
|
||||
{
|
||||
return "livestream";
|
||||
}
|
||||
|
||||
if (config.ResumePointMaxAgeDays > 0
|
||||
&& userData.LastPlayedDate.HasValue
|
||||
&& userData.LastPlayedDate.Value.ToUniversalTime() < DateTime.UtcNow.AddDays(-config.ResumePointMaxAgeDays))
|
||||
{
|
||||
return "older than " + config.ResumePointMaxAgeDays.ToString(CultureInfo.InvariantCulture) + " days";
|
||||
}
|
||||
|
||||
if (config.ResumePointMinPositionSeconds > 0
|
||||
&& userData.PlaybackPositionTicks <= TimeSpan.FromSeconds(config.ResumePointMinPositionSeconds).Ticks)
|
||||
{
|
||||
return "barely started";
|
||||
}
|
||||
|
||||
// Runtime is unknown for live and for some API items; without it there is nothing to
|
||||
// compare the position against, so leave the resume point alone.
|
||||
if (config.ResumePointCompletedPercent > 0
|
||||
&& item.RunTimeTicks.HasValue
|
||||
&& item.RunTimeTicks.Value > 0)
|
||||
{
|
||||
var watchedPercent = userData.PlaybackPositionTicks * 100.0 / item.RunTimeTicks.Value;
|
||||
if (watchedPercent >= config.ResumePointCompletedPercent)
|
||||
{
|
||||
return "watched to the end";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Zeroes the resume position, which removes the item from "Continue Watching" while
|
||||
/// leaving it unwatched so it can still be played again from the start.
|
||||
/// </summary>
|
||||
/// <param name="item">The item to clear.</param>
|
||||
/// <param name="user">The user to clear it for.</param>
|
||||
/// <param name="userData">The user data to update.</param>
|
||||
/// <param name="reason">A short reason, logged for traceability.</param>
|
||||
/// <returns>True if the resume point was cleared.</returns>
|
||||
private bool ClearResumePoint(BaseItem item, User user, UserItemData userData, string reason)
|
||||
{
|
||||
try
|
||||
{
|
||||
userData.PlaybackPositionTicks = 0;
|
||||
|
||||
_userDataManager.SaveUserData(
|
||||
user,
|
||||
item,
|
||||
userData,
|
||||
UserDataSaveReason.UpdateUserData,
|
||||
CancellationToken.None);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Cleared resume point for '{Name}' (user {User}, reason: {Reason})",
|
||||
item.Name,
|
||||
user.Username,
|
||||
reason);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to clear resume point for '{Name}'", item.Name);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -900,9 +900,15 @@ public class StreamProxyService : IStreamProxyService
|
||||
/// <param name="manifestContent">The variant manifest content.</param>
|
||||
/// <param name="baseProxyUrl">The base proxy URL (without query params).</param>
|
||||
/// <param name="queryParams">Query parameters to append to rewritten URLs (e.g., "?token=abc").</param>
|
||||
/// <param name="isLiveStream">Whether this is a livestream; if so an EXT-X-START offset is injected.</param>
|
||||
/// <returns>The rewritten manifest content.</returns>
|
||||
public string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams)
|
||||
public string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams, bool isLiveStream = false)
|
||||
{
|
||||
if (isLiveStream)
|
||||
{
|
||||
manifestContent = InjectLiveStartOffset(manifestContent);
|
||||
}
|
||||
|
||||
string RewriteUrl(string url)
|
||||
{
|
||||
if (url.Contains("://", StringComparison.Ordinal))
|
||||
@@ -953,6 +959,82 @@ public class StreamProxyService : IStreamProxyService
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Injects an <c>#EXT-X-START:TIME-OFFSET</c> tag into a live media playlist so that players
|
||||
/// (notably ExoPlayer on Android TV) begin a few segments back from the volatile live edge
|
||||
/// instead of at the edge itself, which otherwise causes stalling/jumping until a manual skip.
|
||||
/// The offset is sized relative to the playlist's own <c>EXT-X-TARGETDURATION</c> to stay
|
||||
/// RFC 8216 compliant (≥ 3 target durations from the edge for live playlists).
|
||||
/// </summary>
|
||||
/// <param name="manifestContent">The media playlist content.</param>
|
||||
/// <returns>The playlist with the tag injected, or unchanged if it is not applicable.</returns>
|
||||
private string InjectLiveStartOffset(string manifestContent)
|
||||
{
|
||||
var segmentsBack = Plugin.Instance?.Configuration?.LiveStartSegmentsBack ?? 3;
|
||||
if (segmentsBack <= 0)
|
||||
{
|
||||
return manifestContent; // Disabled by config.
|
||||
}
|
||||
|
||||
// Only a media playlist (has segments, no variant list) should carry EXT-X-START.
|
||||
// A master/multivariant playlist or a VOD playlist (#EXT-X-ENDLIST) is left untouched.
|
||||
if (!manifestContent.Contains("#EXTINF", StringComparison.Ordinal)
|
||||
|| manifestContent.Contains("#EXT-X-STREAM-INF", StringComparison.Ordinal)
|
||||
|| manifestContent.Contains("#EXT-X-ENDLIST", StringComparison.Ordinal))
|
||||
{
|
||||
return manifestContent;
|
||||
}
|
||||
|
||||
// Don't add a second tag if the source already specified a start point.
|
||||
if (manifestContent.Contains("#EXT-X-START", StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogDebug("Live playlist already contains #EXT-X-START; leaving as-is");
|
||||
return manifestContent;
|
||||
}
|
||||
|
||||
var targetDurationMatch = Regex.Match(manifestContent, @"#EXT-X-TARGETDURATION:(\d+(?:\.\d+)?)");
|
||||
if (!targetDurationMatch.Success
|
||||
|| !double.TryParse(
|
||||
targetDurationMatch.Groups[1].Value,
|
||||
System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out var targetDuration)
|
||||
|| targetDuration <= 0)
|
||||
{
|
||||
_logger.LogDebug("Could not read EXT-X-TARGETDURATION; skipping EXT-X-START injection");
|
||||
return manifestContent;
|
||||
}
|
||||
|
||||
// RFC 8216: TIME-OFFSET SHOULD NOT be within 3 target durations of the live edge.
|
||||
var clampedSegments = Math.Max(3, segmentsBack);
|
||||
var offsetSeconds = clampedSegments * targetDuration;
|
||||
var startTag = string.Format(
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
"#EXT-X-START:TIME-OFFSET=-{0:0.###},PRECISE=YES",
|
||||
offsetSeconds);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Injecting {StartTag} into live playlist (targetDuration={TargetDuration}s, segmentsBack={SegmentsBack})",
|
||||
startTag,
|
||||
targetDuration,
|
||||
clampedSegments);
|
||||
|
||||
// Insert after the #EXTM3U header so it sits with the other top-level tags.
|
||||
var headerIndex = manifestContent.IndexOf("#EXTM3U", StringComparison.Ordinal);
|
||||
if (headerIndex < 0)
|
||||
{
|
||||
return startTag + "\n" + manifestContent;
|
||||
}
|
||||
|
||||
var lineEnd = manifestContent.IndexOf('\n', headerIndex);
|
||||
if (lineEnd < 0)
|
||||
{
|
||||
return manifestContent + "\n" + startTag;
|
||||
}
|
||||
|
||||
return manifestContent[..(lineEnd + 1)] + startTag + "\n" + manifestContent[(lineEnd + 1)..];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up old and expired stream mappings.
|
||||
/// </summary>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -24,4 +24,17 @@ public static class UrnHelper
|
||||
return guid.ToString();
|
||||
}
|
||||
#pragma warning restore CA5351
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a URN refers to a livestream (scheduled or continuous).
|
||||
/// This is a URN-only heuristic that needs no API call, so it stays usable long after
|
||||
/// the broadcast has ended and the media composition is no longer worth fetching.
|
||||
/// </summary>
|
||||
/// <param name="urn">The URN to inspect. May be null or empty.</param>
|
||||
/// <returns>True if the URN identifies livestream content.</returns>
|
||||
public static bool IsLivestreamUrn(string? urn)
|
||||
{
|
||||
return !string.IsNullOrEmpty(urn)
|
||||
&& urn.Contains("livestream", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ Then install "SRF Play" from the plugin catalog.
|
||||
- **Live Sports Streaming** - Watch scheduled sports events (skiing, Formula 1, football, tennis, etc.)
|
||||
- Support for all Swiss broadcasting units (SRF, RTS, RSI, RTR, SWI)
|
||||
- Automatic content expiration handling
|
||||
- Continue Watching cleanup — ended livestreams and abandoned playback don't pile up in the resume row
|
||||
- Latest and trending content discovery
|
||||
- Quality selection (Auto lets CDN decide, SD prefers 480p/360p, HD prefers 1080p/720p)
|
||||
- HLS streaming support with Akamai token authentication
|
||||
@@ -98,9 +99,20 @@ Examples:
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- .NET 8.0 SDK
|
||||
- .NET 10.0 SDK (builds all three targets below)
|
||||
- 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
|
||||
|
||||
```bash
|
||||
@@ -110,7 +122,7 @@ dotnet build
|
||||
|
||||
### 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
|
||||
|
||||
@@ -137,6 +149,16 @@ The compiled plugin will be in `bin/Debug/net8.0/`
|
||||
- **Proxy Address**: Proxy server URL (e.g., http://proxy.example.com:8080)
|
||||
- **Proxy Username**: Optional authentication username
|
||||
- **Proxy Password**: Optional authentication password
|
||||
- **Continue Watching Cleanup**: Stops stale SRF Play entries accumulating in the resume row
|
||||
- **Clean up stale resume points**: Enables the daily "Clean Up SRF Play Continue Watching" task (4 AM)
|
||||
- **Never keep a resume point for livestreams**: Clears the position the moment a livestream stops
|
||||
- **Maximum Resume Point Age**: Clear entries untouched for this many days (default 30, 0 disables)
|
||||
- **Minimum Resume Position**: Clear entries at or below this position (default 60s, 0 disables)
|
||||
- **Finished Threshold**: Clear entries at or beyond this share of the runtime (default 92%, 0 disables)
|
||||
|
||||
Clearing a resume point only resets the playback position. Nothing is deleted, the item stays
|
||||
unwatched, and only SRF Play items are ever touched. The plugin config page also has
|
||||
**Clean Up Stale Entries Now** and **Clear All SRF Play Entries** buttons for running it on demand.
|
||||
|
||||
For detailed proxy setup instructions, see [PROXY_SETUP_GUIDE.md](PROXY_SETUP_GUIDE.md).
|
||||
|
||||
|
||||
@@ -100,6 +100,8 @@
|
||||
<Rule Id="CA1308" Action="None" />
|
||||
<!-- disable warning CA1848: Use the LoggerMessage delegates -->
|
||||
<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 -->
|
||||
<Rule Id="CA2101" Action="None" />
|
||||
<!-- disable warning CA2234: Pass System.Uri objects instead of strings -->
|
||||
|
||||
@@ -8,6 +8,30 @@
|
||||
"category": "Live TV",
|
||||
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
|
||||
"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",
|
||||
"changelog": "Nightly build 1.0.20260903.198 (699b543)",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260903.198.zip",
|
||||
"checksum": "fcd415e82c95a6fe1e34db1d187a684f",
|
||||
"timestamp": "2026-09-03T19:09:41Z"
|
||||
},
|
||||
{
|
||||
"version": "1.0.20260627.189",
|
||||
"changelog": "Nightly build 1.0.20260627.189 (7c81ef9)",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260627.189.zip",
|
||||
"checksum": "553ac3f806bce24e5aae3b2c8d17395b",
|
||||
"timestamp": "2026-06-27T13:46:17Z"
|
||||
},
|
||||
{
|
||||
"version": "1.0.20260627.185",
|
||||
"changelog": "Nightly build 1.0.20260627.185 (1101385)",
|
||||
|
||||
@@ -8,6 +8,30 @@
|
||||
"category": "Live TV",
|
||||
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
|
||||
"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",
|
||||
"changelog": "Release 1.1.1",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.1.1/srfplay_1.1.1.0.zip",
|
||||
"checksum": "4354a495d2d24ab2313c96bb5ea837bb",
|
||||
"timestamp": "2026-08-12T16:59:30Z"
|
||||
},
|
||||
{
|
||||
"version": "1.1.0",
|
||||
"changelog": "Release 1.1.0",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.1.0/srfplay_1.1.0.0.zip",
|
||||
"checksum": "2094b00b29f79e7625fcd3ebf046674f",
|
||||
"timestamp": "2026-06-27T09:33:04Z"
|
||||
},
|
||||
{
|
||||
"version": "0.0.0.0",
|
||||
"changelog": "Latest Build",
|
||||
|
||||
Executable
+106
@@ -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"
|
||||
Reference in New Issue
Block a user