Compare commits

..
Author SHA1 Message Date
dtourolleandClaude Opus 5.5 71a65aab6e Fix recordings dying mid-stream; record to growing .ts with live buffer playback
🏗️ Build Plugin / build (push) Successful in 12m44s
Nightly Build / nightly-build (push) Successful in 2m49s
🧪 Test Plugin / test (push) Successful in 11m18s
🚀 Release Plugin / build-and-release (push) Successful in 12m24s
A recording of a live event crashed partway through: when its short
Akamai token expired, the proxy's stale-alias check swapped the
recording's mapping to the most recently registered deferred stream,
of any content. Segment requests were then built against the wrong CDN
path, returned 403, and ffmpeg exited, splitting the recording into
several files.

Proxy:
- Never swap URN-backed livestreams; they refresh themselves from the
  API. Only consider swap candidates with the same URN.

Recording lifecycle:
- Complete a recording once its stream has been gone from the API for
  5 minutes (broadcasts can end before ValidTo).
- Fail a recording still waiting for its stream after ValidTo (or 12h
  after ValidFrom) instead of retrying forever.
- Wait for a DVR window whose start= is still in the future instead of
  starting ffmpeg against a 400/404.
- Keep the original start time across restarts; don't mark a recording
  failed on shutdown cancellation.

Growing .ts recordings:
- ffmpeg writes MPEG-TS (-c copy -copyts) to stdout and the plugin
  appends it to one file per recording, so restarts continue the same
  file and timeline.
- The Recordings folder lists any recording with a file on disk, not
  only completed ones.
- In-progress recordings are exposed as infinite streams reading through
  a new endpoint that follows the growing file (same pattern as
  Jellyfin's DVR), so they can be watched from the start like a
  livestream buffer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:14:24 -04:00
Gitea Actions c6a1fd9f63 Update manifest.json for version 1.3.0 2026-09-25 23:32:45 +00:00
Gitea Actions fb32d5c5e2 Nightly: 1.0.20260925.219 (95ce4ed) 2026-09-25 23:30:39 +00:00
dtourolleandClaude Opus 5.5 95ce4edf9b Show times in a configurable time zone
Nightly Build / nightly-build (push) Successful in 1m32s
🧪 Test Plugin / test (push) Successful in 48s
🚀 Release Plugin / build-and-release (push) Successful in 1m18s
🏗️ Build Plugin / build (push) Successful in 12m28s
SRG sends times with an offset (e.g. 13:55+02:00). They are stored as
DateTime, which deserialisation converts to server-local time, and two
places then wrote that straight into text: the "[dd.MM HH:mm]" prefix
on upcoming livestream names and the timestamp in recording file names
(DateTime.Now). On a server running in UTC both were two hours behind
Swiss time, e.g. a qualifying starting at 13:55 was listed as 11:55.

A new "Display Time Zone" setting (IANA id, e.g. Europe/Zurich) now
decides the zone for both; empty keeps the server's zone, and an
unknown id falls back to it rather than breaking the listing.
Utilities/DisplayTime does the conversion.

Also fixes PremiereDate on upcoming livestreams, which was set to that
server-local value although Jellyfin expects UTC. It was only correct
on UTC servers; it is now converted, like the other channel items.

Verified on simulated UTC and Europe/Paris servers: the same instant
gives 11:55 / 13:55 / 07:55 for server zone / Europe/Zurich /
America/New_York, and PremiereDate is 11:55 UTC on both.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 19:29:05 -04:00
Gitea Actions 536fef85a8 Nightly: 1.0.20260924.216 (3c321b4) 2026-09-24 03:01:30 +00:00
dtourolleandClaude Opus 5.5 3c321b4036 ci: run on any runner, amd64 or arm64
🧪 Test Plugin / test (push) Successful in 13m13s
Nightly Build / nightly-build (push) Successful in 1m35s
🏗️ Build Plugin / build (push) Successful in 12m26s
The builder image is now multi-arch (linux/amd64 + linux/arm64, pushed
as srfplay-builder:latest after building all three plugin zips in each
variant), so jobs no longer need an amd64 host. runs-on moves from
linux/amd64 to ubuntu-latest, which draco-x86, freebox and oracle-a1
all carry, so a job no longer waits behind long jobs on draco while
the ARM runners sit idle.

.gitea/runner/cloud-init.yaml sets up such a runner on an Oracle Cloud
Always Free Ampere VM (Ubuntu 24.04): Docker, act_runner registered
instance-wide with the instance's label set, and a weekly image prune.
The registration token and the optional console password hash are
placeholders here and must be filled in locally before use.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 03:58:55 +02:00
Gitea Actions 6822ea7202 Update manifest.json for version 1.2.1 2026-09-24 01:16:46 +00:00
Gitea Actions 8a5223b960 Nightly: 1.0.20260924.212 (db9461c) 2026-09-24 01:14:34 +00:00
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
dtourolleandClaude Opus 5 d0d5d06a5e ci: move builder image to the .NET 10 SDK
🏗️ Build Plugin / build (push) Successful in 1m36s
Nightly Build / nightly-build (push) Successful in 1m35s
🧪 Test Plugin / test (push) Successful in 1m8s
🚀 Release Plugin / build-and-release (push) Successful in 1m40s
Jellyfin 12.0.0 targets net10.0, and the .NET 8 SDK refuses to even restore
a project that lists net10.0 among its TargetFrameworks (NETSDK1045) -- so
the multi-targeted net8.0;net10.0 build that will produce one plugin DLL per
Jellyfin generation cannot start until the builder image moves first.

Verified as a drop-in against the current net8-only master, inside the image:
restore + build are clean (0 errors, 0 warnings) and jprm produces the same
two-file zip as before. Analyzer levels still track each TFM, so the net8.0
leg stays warning-free; CA1873 will only need silencing on the net10.0 leg
once multi-targeting lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 19:24:21 +02:00
Gitea Actions 97b644a3b4 Nightly: 1.0.20260903.198 (699b543) 2026-09-03 19:09:41 +00:00
dtourolle 699b5432b9 Merge PR #2: the nightly manifest is edited on a stale master
🏗️ Build Plugin / build (push) Successful in 1m30s
Nightly Build / nightly-build (push) Successful in 1m45s
🧪 Test Plugin / test (push) Successful in 1m6s
2026-09-03 18:48:41 +00:00
dtourolleandClaude Opus 5 7e1973aca7 fix(ci): the nightly manifest is edited on a stale master
🏗️ Build Plugin / build (pull_request) Successful in 3m1s
🧪 Test Plugin / test (pull_request) Successful in 45s
`git checkout master` after `git fetch origin master` switches to the
LOCAL master -- the one actions/checkout left at this run's own SHA --
not the ref just fetched. A nightly build takes long enough that master
has usually moved by the time it finishes, so the manifest gets its new
entry prepended to a stale copy and the push is rejected:

    ! [rejected]  master -> master (non-fast-forward)

Run 1045 failed this way, and the log is unambiguous about it: "Your
branch is behind 'origin/master' by 1 commit, and can be fast-forwarded"
appears one line before the commit that could not be pushed.

`checkout -B master origin/master` is the fix, and it is right rather
than merely convenient. This step appends one version entry to the
manifest as it stands on master, so the base it edits has to be master.
Editing the copy this build happened to start from and then forcing it
through would silently drop whatever landed in between.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 20:36:09 +02:00
Gitea Actions 47bf506dc6 Update manifest.json for version 1.1.1 2026-08-12 16:59:31 +00:00
33 changed files with 1025 additions and 233 deletions
+58
View File
@@ -0,0 +1,58 @@
#cloud-config
# Gitea Actions runner on an Oracle Cloud Always Free Ampere (ARM) VM, shared by
# every repo on the instance.
#
# Paste this into "Advanced options -> Management -> Initialization script" when
# creating the instance (image: Canonical Ubuntu 24.04, shape: VM.Standard.A1.Flex).
# Before pasting, replace REGISTRATION_TOKEN below with an instance-wide token:
# Site Administration -> Actions -> Runners -> Create new runner
# (a token from repo Settings would limit the runner to that one repo).
#
# The runner only makes outbound connections (to Gitea and container registries),
# so the VM needs no inbound ports beyond SSH.
#
# Labels follow the instance convention (same set as the freebox ARM runner).
# ubuntu-latest / ubuntu-22.04 are shared with the amd64 runner, so a job asking
# for them may land on either architecture; ask for linux/arm64 or linux/amd64
# when a job needs a specific one. SRF Play jobs run in the multi-arch
# srfplay-builder image, so they work on both.
# Optional: a password for the "ubuntu" user, so you can log in through the Oracle
# serial console ("Launch Cloud Shell connection") without an SSH key. Generate the
# hash locally with: openssl passwd -6
# then uncomment and paste it in. SSH still only accepts keys.
#chpasswd:
# expire: false
# users:
# - name: ubuntu
# password: "$6$...paste the hash here..."
package_update: true
packages:
- docker.io
- docker-compose-v2
write_files:
- path: /opt/act_runner/docker-compose.yml
permissions: "0600"
content: |
services:
runner:
image: gitea/act_runner:latest
restart: unless-stopped
environment:
GITEA_INSTANCE_URL: https://gitea.tourolle.paris
GITEA_RUNNER_REGISTRATION_TOKEN: REGISTRATION_TOKEN
GITEA_RUNNER_NAME: oracle-a1
# Comma-separated with no spaces: label:docker://<default job image>
GITEA_RUNNER_LABELS: "ubuntu-latest:docker://gitea/runner-images:ubuntu-latest,ubuntu-22.04:docker://gitea/runner-images:ubuntu-22.04,linux-arm64:docker://gitea/runner-images:ubuntu-latest,self-hosted:docker://gitea/runner-images:ubuntu-latest,linux/arm64:docker://gitea/runner-images:ubuntu-latest"
volumes:
# Registration is stored here, so the token is only used once.
- ./data:/data
- /var/run/docker.sock:/var/run/docker.sock
runcmd:
- systemctl enable --now docker
- cd /opt/act_runner && docker compose up -d
# Weekly clean-up so pulled images and build caches do not fill the boot volume.
- echo '0 4 * * 0 root docker system prune -af --filter "until=168h" >/dev/null 2>&1' > /etc/cron.d/docker-prune
+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
+7 -18
View File
@@ -15,7 +15,7 @@ on:
jobs:
build:
runs-on: linux/amd64
runs-on: ubuntu-latest
container:
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
@@ -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
+44 -53
View File
@@ -11,7 +11,7 @@ on:
jobs:
nightly-build:
runs-on: linux/amd64
runs-on: ubuntu-latest
container:
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
@@ -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
+29 -49
View File
@@ -13,7 +13,7 @@ on:
jobs:
build-and-release:
runs-on: linux/amd64
runs-on: ubuntu-latest
container:
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
@@ -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
+1 -1
View File
@@ -17,7 +17,7 @@ on:
jobs:
test:
runs-on: linux/amd64
runs-on: ubuntu-latest
container:
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
+1
View File
@@ -4,3 +4,4 @@ obj/
.idea/
artifacts
*.log
.test-plugin/
+6 -3
View File
@@ -1,9 +1,12 @@
# SRFPlay Builder Image
# Pre-built image with .NET SDK and JPRM for building Jellyfin plugins
# Build: docker build -f Dockerfile.builder -t gitea.tourolle.paris/dtourolle/srfplay-builder:latest .
# Push: docker push gitea.tourolle.paris/dtourolle/srfplay-builder:latest
# Multi-arch (amd64 + arm64) so runners on ARM hosts such as Oracle Cloud's free
# Ampere VMs can use it too; the plugin DLLs it produces are identical either way.
# Build + push (needs a docker-container buildx builder and arm64 binfmt/QEMU):
# docker buildx build -f Dockerfile.builder --platform linux/amd64,linux/arm64 \
# -t gitea.tourolle.paris/dtourolle/srfplay-builder:latest --push .
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 \
+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>
<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>
@@ -81,6 +81,13 @@ public class RecordingEntry
[JsonPropertyName("recordingEndedAt")]
public DateTime? RecordingEndedAt { get; set; }
/// <summary>
/// Gets or sets when the stream disappeared from the API after recording had started.
/// Used to detect that a broadcast ended before its scheduled ValidTo.
/// </summary>
[JsonPropertyName("streamLostAt")]
public DateTime? StreamLostAt { get; set; }
/// <summary>
/// Gets or sets the file size in bytes.
/// </summary>
@@ -98,4 +105,10 @@ public class RecordingEntry
/// </summary>
[JsonPropertyName("createdAt")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// Gets a value indicating whether the output file may still grow (recording, or waiting to resume).
/// </summary>
[JsonIgnore]
public bool IsInProgress => State is RecordingState.Recording or RecordingState.WaitingForStream;
}
@@ -28,6 +28,8 @@ namespace Jellyfin.Plugin.SRFPlay.Channels;
/// </summary>
public abstract class SrgChannelBase : IChannel, IHasCacheKey
{
private const string InProgressPrefix = "● REC · ";
private readonly ILogger<SrgChannelBase> _logger;
private readonly IContentRefreshService _contentRefreshService;
private readonly IStreamUrlResolver _streamResolver;
@@ -303,8 +305,8 @@ public abstract class SrgChannelBase : IChannel, IHasCacheKey
if (matchingEvent?.ValidFrom != null)
{
var eventTime = matchingEvent.ValidFrom.Value;
item.Name = $"[{eventTime:dd.MM HH:mm}] {matchingEvent.Title}";
item.PremiereDate = eventTime;
item.Name = $"[{DisplayTime.From(eventTime):dd.MM HH:mm}] {matchingEvent.Title}";
item.PremiereDate = eventTime.ToUniversalTime();
}
}
}
@@ -320,15 +322,10 @@ public abstract class SrgChannelBase : IChannel, IHasCacheKey
{
var items = new List<ChannelItemInfo>();
var unit = Unit.ToLowerString();
var recordings = _recordingService.GetRecordings(RecordingState.Completed);
var serverBaseUrl = _mediaSourceFactory.GetServerBaseUrl();
foreach (var recording in recordings)
foreach (var recording in _recordingService.GetPlayableRecordings())
{
if (string.IsNullOrEmpty(recording.OutputPath) || !System.IO.File.Exists(recording.OutputPath))
{
continue;
}
// Only show recordings belonging to this unit. Recordings created before the
// BusinessUnit tag existed have an empty value and are shown everywhere so they
// are not lost.
@@ -338,43 +335,72 @@ public abstract class SrgChannelBase : IChannel, IHasCacheKey
continue;
}
var fileInfo = new System.IO.FileInfo(recording.OutputPath);
var outputPath = recording.OutputPath!;
var itemId = $"recording_{recording.Id}";
var container = System.IO.Path.GetExtension(outputPath).TrimStart('.').ToLowerInvariant();
var name = recording.IsInProgress ? $"{InProgressPrefix}{recording.Title}" : recording.Title;
var mediaSource = new MediaSourceInfo
MediaSourceInfo mediaSource;
if (recording.IsInProgress)
{
Id = itemId,
Name = recording.Title,
Path = recording.OutputPath,
Protocol = MediaProtocol.File,
Container = "mkv",
SupportsDirectPlay = true,
SupportsDirectStream = true,
SupportsTranscoding = true,
IsRemote = false,
Size = fileInfo.Length,
Type = MediaSourceType.Default
};
// Same approach as Jellyfin's own DVR: the file is still growing, so treat it as a live
// stream and have ffmpeg read it through an endpoint that follows the file.
mediaSource = new MediaSourceInfo
{
Id = itemId,
Name = name,
Path = outputPath,
Protocol = MediaProtocol.File,
EncoderPath = $"{serverBaseUrl}/Plugins/SRFPlay/Recording/{recording.Id}/stream",
EncoderProtocol = MediaProtocol.Http,
Container = container,
SupportsDirectPlay = false,
SupportsDirectStream = true,
SupportsTranscoding = true,
IsInfiniteStream = true,
IgnoreDts = true,
IgnoreIndex = true,
IsRemote = false,
Type = MediaSourceType.Default
};
}
else
{
mediaSource = new MediaSourceInfo
{
Id = itemId,
Name = name,
Path = outputPath,
Protocol = MediaProtocol.File,
Container = container,
SupportsDirectPlay = true,
SupportsDirectStream = true,
SupportsTranscoding = true,
IsRemote = false,
Size = new System.IO.FileInfo(outputPath).Length,
Type = MediaSourceType.Default
};
}
var item = new ChannelItemInfo
{
Id = itemId,
Name = recording.Title,
Name = name,
Overview = recording.Description,
Type = ChannelItemType.Media,
ContentType = ChannelMediaContentType.Movie,
MediaType = ChannelMediaType.Video,
DateCreated = recording.RecordingStartedAt,
ImageUrl = !string.IsNullOrEmpty(recording.ImageUrl)
? CreateProxiedImageUrl(recording.ImageUrl, _mediaSourceFactory.GetServerBaseUrl())
: CreatePlaceholderImageUrl(recording.Title, _mediaSourceFactory.GetServerBaseUrl()),
? CreateProxiedImageUrl(recording.ImageUrl, serverBaseUrl)
: CreatePlaceholderImageUrl(recording.Title, serverBaseUrl),
MediaSources = new List<MediaSourceInfo> { mediaSource }
};
items.Add(item);
}
_logger.LogInformation("Returning {Count} completed recordings as channel items", items.Count);
_logger.LogInformation("Returning {Count} recordings as channel items", items.Count);
return items;
}
@@ -492,8 +518,10 @@ public abstract class SrgChannelBase : IChannel, IHasCacheKey
var timeBucket = new DateTime(now.Year, now.Month, now.Day, now.Hour, (now.Minute / 15) * 15, 0);
var timeKey = timeBucket.ToString("yyyy-MM-dd-HH-mm", CultureInfo.InvariantCulture);
var recordingCount = _recordingService.GetRecordings(RecordingState.Completed).Count;
return $"{Unit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{timeKey}_rec{recordingCount}";
// Include in-progress count so items switch from live to finished sources when a recording ends
var recordings = _recordingService.GetPlayableRecordings();
var recordingKey = $"{recordings.Count}_{recordings.Count(r => r.IsInProgress)}";
return $"{Unit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{timeKey}_rec{recordingKey}";
}
private async Task<List<ChannelItemInfo>> ConvertUrnsToChannelItems(List<string> urns, CancellationToken cancellationToken)
@@ -169,6 +169,14 @@ public class PluginConfiguration : BasePluginConfiguration
/// </summary>
public string RecordingOutputPath { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the time zone used for times the plugin writes as text: the start time in
/// upcoming livestream names and the timestamp in recording file names. An IANA id such as
/// "Europe/Zurich"; empty uses the server's time zone. Dates Jellyfin shows itself (e.g.
/// PremiereDate) are stored as UTC and follow each viewer's own time zone regardless.
/// </summary>
public string DisplayTimeZone { 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
@@ -82,6 +82,11 @@
<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="DisplayTimeZone">Display Time Zone</label>
<input id="DisplayTimeZone" name="DisplayTimeZone" type="text" is="emby-input" placeholder="e.g., Europe/Zurich" />
<div class="fieldDescription">Time zone for start times in upcoming livestream names and for recording file names. Leave empty to use the server's time zone.</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" />
@@ -200,6 +205,7 @@
document.querySelector('#ProxyPassword').value = config.ProxyPassword || '';
document.querySelector('#PublicServerUrl').value = config.PublicServerUrl || '';
document.querySelector('#RecordingOutputPath').value = config.RecordingOutputPath || '';
document.querySelector('#DisplayTimeZone').value = config.DisplayTimeZone || '';
document.querySelector('#LiveStartSegmentsBack').value = config.LiveStartSegmentsBack != null ? config.LiveStartSegmentsBack : 3;
document.querySelector('#CleanUpResumePoints').checked = config.CleanUpResumePoints !== false;
document.querySelector('#ClearLiveStreamResumePoints').checked = config.ClearLiveStreamResumePoints !== false;
@@ -231,6 +237,7 @@
config.ProxyPassword = document.querySelector('#ProxyPassword').value;
config.PublicServerUrl = document.querySelector('#PublicServerUrl').value;
config.RecordingOutputPath = document.querySelector('#RecordingOutputPath').value;
config.DisplayTimeZone = document.querySelector('#DisplayTimeZone').value.trim();
config.LiveStartSegmentsBack = parseInt(document.querySelector('#LiveStartSegmentsBack').value) || 0;
config.CleanUpResumePoints = document.querySelector('#CleanUpResumePoints').checked;
config.ClearLiveStreamResumePoints = document.querySelector('#ClearLiveStreamResumePoints').checked;
@@ -257,7 +264,7 @@
fetch(ApiClient.serverAddress() + '/Plugins/SRFPlay/Maintenance/ResumePoints/Cleanup?clearAll=' + (clearAll ? 'true' : 'false'), {
method: 'POST',
headers: { 'X-Emby-Token': ApiClient.accessToken() }
headers: { 'Authorization': 'MediaBrowser Token="' + ApiClient.accessToken() + '"' }
})
.then(function(response) {
if (!response.ok) {
@@ -285,7 +292,7 @@
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) {
@@ -1,4 +1,6 @@
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
@@ -19,6 +21,9 @@ namespace Jellyfin.Plugin.SRFPlay.Controllers;
[Authorize]
public class RecordingController : ControllerBase
{
/// <summary>How long a followed stream waits at the end of an in-progress file before giving up.</summary>
private static readonly TimeSpan FollowIdleTimeout = TimeSpan.FromMinutes(2);
private readonly ILogger<RecordingController> _logger;
private readonly IRecordingService _recordingService;
@@ -56,6 +61,67 @@ public class RecordingController : ControllerBase
return File(resourceStream, "text/html");
}
/// <summary>
/// Streams a recording's .ts file. While the recording is still in progress this keeps
/// following the file as it grows, so it can be watched like a livestream buffer.
/// </summary>
/// <param name="id">The recording ID.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The MPEG-TS stream.</returns>
[HttpGet("{id}/stream")]
[AllowAnonymous] // Fetched server-side by Jellyfin/ffmpeg without a token, like the stream proxy
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetRecordingStream(string id, CancellationToken cancellationToken)
{
// The path always comes from our own recording list (which only contains existing files),
// never from the request
var entry = _recordingService.GetPlayableRecordings().FirstOrDefault(r => r.Id == id);
if (entry?.OutputPath is not { } path)
{
return NotFound();
}
Response.ContentType = "video/mp2t";
var input = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan);
await using (input.ConfigureAwait(false))
{
var buffer = new byte[81920];
var lastDataAt = DateTime.UtcNow;
try
{
while (true)
{
var read = await input.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
if (read > 0)
{
await Response.Body.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
lastDataAt = DateTime.UtcNow;
continue;
}
// At the end of the file: keep following while it's still being written. The idle
// limit covers an ffmpeg restart but ends the stream if recording has stalled.
var stillRecording = _recordingService.GetRecording(id)?.IsInProgress == true;
if (!stillRecording || DateTime.UtcNow - lastDataAt > FollowIdleTimeout)
{
break;
}
await Task.Delay(500, cancellationToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// Client went away
}
}
return new EmptyResult();
}
/// <summary>
/// Gets upcoming sport livestreams available for recording.
/// </summary>
@@ -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))
};
}
}
@@ -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;
@@ -77,11 +78,7 @@ public class ResumeCleanupTask : IScheduledTask
// Runs after the 3 AM expiration check so items deleted there are already gone.
return new[]
{
new TaskTriggerInfo
{
Type = TaskTriggerInfo.TriggerDaily,
TimeOfDayTicks = TimeSpan.FromHours(4).Ticks
}
TaskTriggers.Daily(TimeSpan.FromHours(4))
};
}
}
@@ -47,6 +47,19 @@ public interface IRecordingService
/// <returns>List of matching recording entries.</returns>
IReadOnlyList<RecordingEntry> GetRecordings(RecordingState? stateFilter = null);
/// <summary>
/// Gets a single recording by ID.
/// </summary>
/// <param name="recordingId">The recording ID.</param>
/// <returns>The recording entry, or null if not found.</returns>
RecordingEntry? GetRecording(string recordingId);
/// <summary>
/// Gets recordings that have an output file on disk, finished or still in progress.
/// </summary>
/// <returns>Playable recordings, newest first.</returns>
IReadOnlyList<RecordingEntry> GetPlayableRecordings();
/// <summary>
/// Deletes a completed recording (entry and optionally the file).
/// </summary>
@@ -1,6 +1,10 @@
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;
@@ -25,6 +25,15 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
/// </summary>
public class RecordingService : IRecordingService, IDisposable
{
/// <summary>How long the stream may be missing from the API mid-recording before we treat the broadcast as over.</summary>
private static readonly TimeSpan StreamLostGracePeriod = TimeSpan.FromMinutes(5);
/// <summary>When a recording has no ValidTo, how long after ValidFrom to keep waiting for the stream.</summary>
private static readonly TimeSpan NoValidToGiveUpAfter = TimeSpan.FromHours(12);
/// <summary>Longest the scheduler will block waiting for an imminent DVR window start.</summary>
private static readonly TimeSpan MaxInlineStartWait = TimeSpan.FromSeconds(60);
private readonly ILogger<RecordingService> _logger;
private readonly ISRFApiClientFactory _apiClientFactory;
private readonly IStreamProxyService _proxyService;
@@ -32,7 +41,7 @@ public class RecordingService : IRecordingService, IDisposable
private readonly IMediaCompositionFetcher _mediaCompositionFetcher;
private readonly IServerApplicationHost _appHost;
private readonly IMediaEncoder _mediaEncoder;
private readonly ConcurrentDictionary<string, Process> _activeProcesses = new();
private readonly ConcurrentDictionary<string, ActiveRecording> _activeProcesses = new();
private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true };
private readonly SemaphoreSlim _persistLock = new(1, 1);
private readonly SemaphoreSlim _processLock = new(1, 1);
@@ -266,15 +275,7 @@ public class RecordingService : IRecordingService, IDisposable
}
StopFfmpeg(recordingId);
entry.State = RecordingState.Completed;
entry.RecordingEndedAt = DateTime.UtcNow;
if (entry.OutputPath != null && File.Exists(entry.OutputPath))
{
entry.FileSizeBytes = new FileInfo(entry.OutputPath).Length;
}
CompleteRecording(entry, DateTime.UtcNow);
_ = SaveRecordingsAsync();
_logger.LogInformation("Stopped recording '{Title}' ({Id})", entry.Title, recordingId);
@@ -298,6 +299,22 @@ public class RecordingService : IRecordingService, IDisposable
return _recordings.OrderByDescending(r => r.CreatedAt).ToList();
}
/// <inheritdoc />
public RecordingEntry? GetRecording(string recordingId)
{
return GetRecordings(null).FirstOrDefault(r => r.Id == recordingId);
}
/// <inheritdoc />
public IReadOnlyList<RecordingEntry> GetPlayableRecordings()
{
// Gate on the file rather than the state: an in-progress or failed recording
// still has watchable footage.
return GetRecordings(null)
.Where(r => !string.IsNullOrEmpty(r.OutputPath) && File.Exists(r.OutputPath))
.ToList();
}
/// <inheritdoc />
public bool DeleteRecording(string recordingId, bool deleteFile)
{
@@ -369,8 +386,26 @@ public class RecordingService : IRecordingService, IDisposable
{
case RecordingState.Scheduled:
case RecordingState.WaitingForStream:
// Check if it's time to start recording
if (validFromUtc.HasValue && validFromUtc.Value <= now.AddMinutes(2))
// Give up once the broadcast window is over, otherwise we'd retry forever
var giveUpAt = validToUtc ?? validFromUtc?.Add(NoValidToGiveUpAfter);
if (giveUpAt.HasValue && giveUpAt.Value <= now)
{
if (entry.RecordingStartedAt.HasValue)
{
_logger.LogInformation("Recording '{Title}' reached end of broadcast window while waiting for stream, completing", entry.Title);
CompleteRecording(entry, now);
}
else
{
_logger.LogWarning("Stream for '{Title}' never became available (window ended {GiveUpAt}), marking failed", entry.Title, giveUpAt.Value);
entry.State = RecordingState.Failed;
entry.ErrorMessage = "Stream never became available";
entry.RecordingEndedAt = now;
}
changed = true;
}
else if (validFromUtc.HasValue && validFromUtc.Value <= now.AddMinutes(2))
{
_logger.LogInformation(
"Time to start recording '{Title}': ValidFrom={ValidFrom} (UTC: {ValidFromUtc}), Now={Now}",
@@ -389,13 +424,7 @@ public class RecordingService : IRecordingService, IDisposable
{
_logger.LogInformation("Recording '{Title}' reached ValidTo, stopping", entry.Title);
StopFfmpeg(entry.Id);
entry.State = RecordingState.Completed;
entry.RecordingEndedAt = now;
if (entry.OutputPath != null && File.Exists(entry.OutputPath))
{
entry.FileSizeBytes = new FileInfo(entry.OutputPath).Length;
}
CompleteRecording(entry, now);
changed = true;
}
else if (!_activeProcesses.ContainsKey(entry.Id))
@@ -426,7 +455,7 @@ public class RecordingService : IRecordingService, IDisposable
if (chapter == null)
{
_logger.LogDebug("No chapter found for '{Title}', stream may not be live yet", entry.Title);
entry.State = RecordingState.WaitingForStream;
MarkStreamUnavailable(entry);
return true;
}
@@ -437,10 +466,29 @@ public class RecordingService : IRecordingService, IDisposable
if (string.IsNullOrEmpty(streamUrl))
{
_logger.LogDebug("No stream URL available for '{Title}', waiting", entry.Title);
entry.State = RecordingState.WaitingForStream;
MarkStreamUnavailable(entry);
return true;
}
// The CDN rejects a DVR window whose start= is still in the future (400/404).
// Wait briefly if it's imminent, otherwise try again on the next scheduler run.
var windowStart = GetDvrWindowStart(streamUrl);
if (windowStart.HasValue && windowStart.Value > DateTime.UtcNow)
{
var wait = windowStart.Value - DateTime.UtcNow + TimeSpan.FromSeconds(5);
if (wait > MaxInlineStartWait)
{
_logger.LogInformation("Stream for '{Title}' starts at {Start}, waiting", entry.Title, windowStart.Value);
entry.State = RecordingState.WaitingForStream;
return true;
}
_logger.LogInformation("Stream for '{Title}' starts in {Seconds:F0}s, delaying ffmpeg start", entry.Title, wait.TotalSeconds);
await Task.Delay(wait, cancellationToken).ConfigureAwait(false);
}
entry.StreamLostAt = null;
// Register the stream with the proxy so we can use the proxy URL
var itemId = $"rec_{entry.Id}";
var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" || UrnHelper.IsLivestreamUrn(entry.Urn);
@@ -449,22 +497,26 @@ public class RecordingService : IRecordingService, IDisposable
// Build proxy URL for ffmpeg (use localhost for local access)
var proxyUrl = $"{GetServerBaseUrl()}/Plugins/SRFPlay/Proxy/{itemId}/master.m3u8";
// Build output file path
var safeTitle = SanitizeFileName(entry.Title);
var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HHmm", CultureInfo.InvariantCulture);
var outputPath = Path.Combine(GetRecordingOutputPath(), $"{safeTitle}_{timestamp}.mkv");
entry.OutputPath = outputPath;
// Restarts append to the existing .ts so a recording stays one file
var outputPath = entry.OutputPath;
if (string.IsNullOrEmpty(outputPath) || !outputPath.EndsWith(".ts", StringComparison.OrdinalIgnoreCase))
{
var safeTitle = SanitizeFileName(entry.Title);
var timestamp = DisplayTime.Now().ToString("yyyy-MM-dd_HHmm", CultureInfo.InvariantCulture);
outputPath = Path.Combine(GetRecordingOutputPath(), $"{safeTitle}_{timestamp}.ts");
entry.OutputPath = outputPath;
}
// Start ffmpeg
StartFfmpeg(entry.Id, proxyUrl, outputPath);
entry.State = RecordingState.Recording;
entry.RecordingStartedAt = DateTime.UtcNow;
entry.RecordingStartedAt ??= DateTime.UtcNow;
_logger.LogInformation("Started recording '{Title}' to {OutputPath}", entry.Title, outputPath);
return true;
}
catch (Exception ex)
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Failed to start recording '{Title}'", entry.Title);
entry.State = RecordingState.Failed;
@@ -473,6 +525,61 @@ public class RecordingService : IRecordingService, IDisposable
}
}
/// <summary>
/// Handles the API no longer offering a stream. Before recording starts this just means "not live yet";
/// once recording has started it means the broadcast ended (possibly before ValidTo).
/// </summary>
private void MarkStreamUnavailable(RecordingEntry entry)
{
if (!entry.RecordingStartedAt.HasValue)
{
entry.State = RecordingState.WaitingForStream;
return;
}
var now = DateTime.UtcNow;
entry.StreamLostAt ??= now;
if (now - entry.StreamLostAt.Value >= StreamLostGracePeriod)
{
_logger.LogInformation(
"Stream for '{Title}' has been gone since {LostAt}, broadcast has ended - completing recording",
entry.Title,
entry.StreamLostAt.Value);
StopFfmpeg(entry.Id);
CompleteRecording(entry, now);
return;
}
entry.State = RecordingState.WaitingForStream;
}
private static void CompleteRecording(RecordingEntry entry, DateTime now)
{
entry.State = RecordingState.Completed;
entry.RecordingEndedAt = now;
if (entry.OutputPath != null && File.Exists(entry.OutputPath))
{
entry.FileSizeBytes = new FileInfo(entry.OutputPath).Length;
}
}
/// <summary>
/// Reads the DVR window start (unix seconds in the start= query parameter) from a stream URL.
/// </summary>
private static DateTime? GetDvrWindowStart(string streamUrl)
{
if (!Uri.TryCreate(streamUrl, UriKind.Absolute, out var uri))
{
return null;
}
var start = System.Web.HttpUtility.ParseQueryString(uri.Query)["start"];
return long.TryParse(start, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds)
? DateTimeOffset.FromUnixTimeSeconds(seconds).UtcDateTime
: null;
}
private void StartFfmpeg(string recordingId, string inputUrl, string outputPath)
{
var process = new Process
@@ -480,13 +587,16 @@ public class RecordingService : IRecordingService, IDisposable
StartInfo = new ProcessStartInfo
{
FileName = _mediaEncoder.EncoderPath,
Arguments = $"-y -i \"{inputUrl}\" -c copy -movflags +faststart \"{outputPath}\"",
// MPEG-TS on stdout: it has no header/index to finalise, so the file is playable while it
// grows, and we append it ourselves so restarts continue the same file. -copyts keeps the
// broadcast timestamps so an appended restart continues the timeline instead of resetting to 0.
Arguments = $"-i \"{inputUrl}\" -c copy -copyts -f mpegts pipe:1",
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
},
EnableRaisingEvents = true
}
};
process.ErrorDataReceived += (_, args) =>
@@ -497,23 +607,49 @@ public class RecordingService : IRecordingService, IDisposable
}
};
process.Exited += (_, _) =>
{
_logger.LogInformation("ffmpeg process exited for recording {RecordingId} with code {ExitCode}", recordingId, process.ExitCode);
_activeProcesses.TryRemove(recordingId, out _);
};
process.Start();
process.BeginErrorReadLine();
_activeProcesses[recordingId] = process;
_logger.LogInformation("Started ffmpeg (PID {Pid}) for recording {RecordingId}: {Args}", process.Id, recordingId, process.StartInfo.Arguments);
var active = new ActiveRecording(process);
_activeProcesses[recordingId] = active;
active.Pump = PumpToFileAsync(recordingId, active, outputPath);
_logger.LogInformation("Started ffmpeg (PID {Pid}) for recording {RecordingId} -> {OutputPath}: {Args}", process.Id, recordingId, outputPath, process.StartInfo.Arguments);
}
/// <summary>
/// Appends ffmpeg's stdout to the recording file. The recording counts as active until
/// ffmpeg has exited and all of its output is on disk, so a restart never interleaves writes.
/// </summary>
private async Task PumpToFileAsync(string recordingId, ActiveRecording active, string outputPath)
{
try
{
var output = new FileStream(outputPath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete);
await using (output.ConfigureAwait(false))
{
await active.Process.StandardOutput.BaseStream.CopyToAsync(output).ConfigureAwait(false);
}
await active.Process.WaitForExitAsync().ConfigureAwait(false);
_logger.LogInformation("ffmpeg process exited for recording {RecordingId} with code {ExitCode}", recordingId, active.Process.ExitCode);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error writing recording output for {RecordingId}", recordingId);
}
finally
{
// Only remove our own entry; a newer process may already be registered
_activeProcesses.TryRemove(new KeyValuePair<string, ActiveRecording>(recordingId, active));
}
}
private void StopFfmpeg(string recordingId)
{
if (_activeProcesses.TryRemove(recordingId, out var process))
if (_activeProcesses.TryRemove(recordingId, out var active))
{
var process = active.Process;
try
{
if (!process.HasExited)
@@ -529,6 +665,8 @@ public class RecordingService : IRecordingService, IDisposable
}
}
// Let the remaining output reach the file before releasing the process
active.Pump?.Wait(TimeSpan.FromSeconds(10));
process.Dispose();
}
catch (Exception ex)
@@ -578,4 +716,19 @@ public class RecordingService : IRecordingService, IDisposable
_disposed = true;
}
/// <summary>
/// A running ffmpeg process and the task copying its output to disk.
/// </summary>
private sealed class ActiveRecording
{
public ActiveRecording(Process process)
{
Process = process;
}
public Process Process { get; }
public Task? Pump { get; set; }
}
}
@@ -3,7 +3,11 @@ 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;
@@ -71,7 +75,11 @@ public class ResumeCleanupService : IResumeCleanupService
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++;
@@ -132,10 +132,13 @@ public class StreamProxyService : IStreamProxyService
!string.IsNullOrEmpty(streamInfo.AuthenticatedUrl));
// Check for stale alias: only look for fresher stream if current token is EXPIRED or EXPIRING SOON
// Don't replace a valid token (>5s left) with a new deferred registration
if (!streamInfo.NeedsAuthentication && tokenTimeLeft < 5)
// Don't replace a valid token (>5s left) with a new deferred registration.
// Livestreams with a URN refresh themselves from the API, so never swap them: an unrelated
// registration (e.g. from a library refresh) would point the stream at the wrong CDN path.
var selfRefreshing = streamInfo.IsLiveStream && !string.IsNullOrEmpty(streamInfo.Urn);
if (!streamInfo.NeedsAuthentication && tokenTimeLeft < 5 && !selfRefreshing)
{
var freshStream = FindFreshestStream();
var freshStream = FindFreshestStream(streamInfo.Urn);
if (freshStream != null && freshStream.Value.Value.NeedsAuthentication)
{
_logger.LogWarning(
@@ -595,8 +598,9 @@ public class StreamProxyService : IStreamProxyService
/// <summary>
/// Finds the freshest (most recently registered) stream that needs authentication or has a valid token.
/// </summary>
/// <param name="urn">When set, only streams for this URN are considered.</param>
/// <returns>The freshest stream entry, or null if none found.</returns>
private KeyValuePair<string, StreamInfo>? FindFreshestStream()
private KeyValuePair<string, StreamInfo>? FindFreshestStream(string? urn)
{
var now = DateTime.UtcNow;
@@ -604,6 +608,11 @@ public class StreamProxyService : IStreamProxyService
// or have tokens that aren't expired yet
var candidates = _streamMappings.Where(kvp =>
{
if (!string.IsNullOrEmpty(urn) && !string.Equals(kvp.Value.Urn, urn, StringComparison.OrdinalIgnoreCase))
{
return false; // Different content
}
if (kvp.Value.NeedsAuthentication)
{
return true; // Fresh deferred registration
@@ -0,0 +1,42 @@
using System;
namespace Jellyfin.Plugin.SRFPlay.Utilities;
/// <summary>
/// Converts times into the time zone the plugin writes into text (item names, file names).
/// SRG sends times with an offset, and DateTime deserialisation turns them into server-local
/// values, so without this the text follows whatever zone the server happens to run in.
/// </summary>
public static class DisplayTime
{
/// <summary>
/// Converts a time to the configured display time zone (the server's if none is set).
/// </summary>
/// <param name="value">The time. Unspecified values are treated as server-local, like deserialised ones.</param>
/// <returns>The same instant expressed in the display time zone.</returns>
public static DateTime From(DateTime value)
{
return TimeZoneInfo.ConvertTimeFromUtc(value.ToUniversalTime(), GetZone());
}
/// <summary>
/// Gets the current time in the display time zone.
/// </summary>
/// <returns>The current time in the display time zone.</returns>
public static DateTime Now()
{
return TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, GetZone());
}
private static TimeZoneInfo GetZone()
{
var id = Plugin.Instance?.Configuration.DisplayTimeZone;
if (string.IsNullOrWhiteSpace(id))
{
return TimeZoneInfo.Local;
}
// An unknown id falls back to the server's zone rather than breaking listings.
return TimeZoneInfo.TryFindSystemTimeZoneById(id.Trim(), out var zone) ? zone : TimeZoneInfo.Local;
}
}
@@ -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
- .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
@@ -111,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
+2
View File
@@ -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 -->
+88
View File
@@ -8,6 +8,94 @@
"category": "Live TV",
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
"versions": [
{
"version": "1.0.20260925.21912",
"changelog": "Nightly build 1.0.20260925.21912 (95ce4ed)",
"targetAbi": "12.0.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260925.21912.zip",
"checksum": "2256a19f4089e3958821124cc06952f3",
"timestamp": "2026-09-25T23:30:39Z"
},
{
"version": "1.0.20260925.21911",
"changelog": "Nightly build 1.0.20260925.21911 (95ce4ed)",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260925.21911.zip",
"checksum": "488443d6c721244594be8f1cada52a6f",
"timestamp": "2026-09-25T23:30:39Z"
},
{
"version": "1.0.20260925.21909",
"changelog": "Nightly build 1.0.20260925.21909 (95ce4ed)",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260925.21909.zip",
"checksum": "1b1bcaea6e46b3ecf71912fa6d07d985",
"timestamp": "2026-09-25T23:30:39Z"
},
{
"version": "1.0.20260924.21612",
"changelog": "Nightly build 1.0.20260924.21612 (3c321b4)",
"targetAbi": "12.0.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260924.21612.zip",
"checksum": "146824b350e6f10c0861dea198e5dd51",
"timestamp": "2026-09-24T03:01:30Z"
},
{
"version": "1.0.20260924.21611",
"changelog": "Nightly build 1.0.20260924.21611 (3c321b4)",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260924.21611.zip",
"checksum": "e63045bda702234285ee6d2f62034397",
"timestamp": "2026-09-24T03:01:30Z"
},
{
"version": "1.0.20260924.21609",
"changelog": "Nightly build 1.0.20260924.21609 (3c321b4)",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260924.21609.zip",
"checksum": "57592a884c09600d11f82702ac5d1805",
"timestamp": "2026-09-24T03:01:30Z"
},
{
"version": "1.0.20260924.21212",
"changelog": "Nightly build 1.0.20260924.21212 (db9461c)",
"targetAbi": "12.0.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260924.21212.zip",
"checksum": "b497b4f8a9c5822909b6df0ed37b8b3d",
"timestamp": "2026-09-24T01:14:34Z"
},
{
"version": "1.0.20260924.21211",
"changelog": "Nightly build 1.0.20260924.21211 (db9461c)",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260924.21211.zip",
"checksum": "e3b98d2db0253dc42c3cc87645bffa0e",
"timestamp": "2026-09-24T01:14:34Z"
},
{
"version": "1.0.20260924.21209",
"changelog": "Nightly build 1.0.20260924.21209 (db9461c)",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260924.21209.zip",
"checksum": "1f4b3fe079d32a08968d7dfb99dc813f",
"timestamp": "2026-09-24T01:14:34Z"
},
{
"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)",
+64
View File
@@ -8,6 +8,70 @@
"category": "Live TV",
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
"versions": [
{
"version": "1.3.0.12",
"changelog": "Release 1.3.0",
"targetAbi": "12.0.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.3.0/srfplay_1.3.0.12.zip",
"checksum": "117970ad31cb34b6cccaf90262c51b4b",
"timestamp": "2026-09-25T23:32:44Z"
},
{
"version": "1.3.0.11",
"changelog": "Release 1.3.0",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.3.0/srfplay_1.3.0.11.zip",
"checksum": "2136a102d793b64561656d0c0b04dd6f",
"timestamp": "2026-09-25T23:32:44Z"
},
{
"version": "1.3.0.9",
"changelog": "Release 1.3.0",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.3.0/srfplay_1.3.0.9.zip",
"checksum": "47008ac9ba658868bb8fc3f56c8c7e02",
"timestamp": "2026-09-25T23:32:44Z"
},
{
"version": "1.2.1.12",
"changelog": "Release 1.2.1",
"targetAbi": "12.0.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.2.1/srfplay_1.2.1.12.zip",
"checksum": "e7fe29977ed387f5f324b58aa40d8ebb",
"timestamp": "2026-09-24T01:16:46Z"
},
{
"version": "1.2.1.11",
"changelog": "Release 1.2.1",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.2.1/srfplay_1.2.1.11.zip",
"checksum": "f69293b34c2e0e5fc5f2f2117ffb3ded",
"timestamp": "2026-09-24T01:16:46Z"
},
{
"version": "1.2.1.9",
"changelog": "Release 1.2.1",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.2.1/srfplay_1.2.1.9.zip",
"checksum": "f18230d45e510cf60dce3badcd3f29a1",
"timestamp": "2026-09-24T01:16:46Z"
},
{
"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",
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"