Compare commits

...
75 Commits
Author SHA1 Message Date
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
dtourolleandClaude Opus 5 390146e8d4 Clean up stale Continue Watching entries
🏗️ Build Plugin / build (push) Successful in 1m43s
🧪 Test Plugin / test (push) Successful in 34s
🚀 Release Plugin / build-and-release (push) Successful in 51s
Nightly Build / nightly-build (push) Failing after 50s
Livestreams and ended broadcasts accumulated in every user's resume row
because Jellyfin saves a playback position for channel items regardless
of whether the position means anything. A livestream has nothing to
return to, so the entry stayed pinned forever.

Two parts:

- PlaybackResumeGuard, an IHostedService on ISessionManager.PlaybackStopped.
  Jellyfin saves the resume point before raising the event, so the guard
  zeroes it afterwards for livestreams. Stops new entries at the source.
- ResumeCleanupService plus a daily 4 AM task (after the 3 AM expiration
  check) for the existing backlog. Clears livestreams, resume points older
  than N days, positions under N seconds and playback past N% of runtime,
  each threshold configurable.

Only plugin-owned items are touched, matched by the SRF provider ID with a
fallback to the owning channel ID so recordings are covered too. Clearing
sets PlaybackPositionTicks to 0 and leaves Played false: nothing is deleted
and the item stays unwatched.

Config page gains the thresholds plus "Clean Up Stale Entries Now" and
"Clear All SRF Play Entries" buttons, backed by MaintenanceController
under RequiresElevation.

Also folds the duplicated urn.Contains("livestream") check in
MediaSourceFactory and RecordingService into UrnHelper.IsLivestreamUrn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 18:55:41 +02:00
Gitea Actions a9db0aa09c Nightly: 1.0.20260627.189 (7c81ef9) 2026-06-27 13:46:17 +00:00
dtourolle 7c81ef94ca fix Start stream 3 seconds behind edge
🏗️ Build Plugin / build (push) Successful in 33s
Nightly Build / nightly-build (push) Successful in 44s
🧪 Test Plugin / test (push) Successful in 29s
2026-06-27 15:44:56 +02:00
Gitea Actions f715130302 Update manifest.json for version 1.1.0 2026-06-27 09:33:05 +00:00
Gitea Actions affc87bd38 Nightly: 1.0.20260627.185 (1101385)
🚀 Release Plugin / build-and-release (push) Successful in 31s
2026-06-27 09:30:57 +00:00
dtourolle 1101385107 Really fix CI
🏗️ Build Plugin / build (push) Successful in 36s
Nightly Build / nightly-build (push) Successful in 43s
🧪 Test Plugin / test (push) Successful in 28s
2026-06-27 11:29:32 +02:00
dtourolle 4fc79f39f7 Fix CI
🏗️ Build Plugin / build (push) Successful in 41s
Nightly Build / nightly-build (push) Failing after 35s
🧪 Test Plugin / test (push) Successful in 30s
2026-06-27 11:23:53 +02:00
dtourolle 5875f81b9b fix correct guid
🏗️ Build Plugin / build (push) Successful in 42s
Nightly Build / nightly-build (push) Failing after 35s
🧪 Test Plugin / test (push) Successful in 28s
2026-06-27 11:18:48 +02:00
dtourolle cedef6d6aa Add nightly job
🏗️ Build Plugin / build (push) Successful in 29s
Nightly Build / nightly-build (push) Has been cancelled
🧪 Test Plugin / test (push) Has been cancelled
2026-06-27 11:17:57 +02:00
dtourolle 4b5d7e2a7f info add github cross-login info 2026-06-27 11:17:57 +02:00
Gitea Actions 8281960f0b Update manifest.json for latest build (7a719ee) 2026-06-27 09:01:57 +00:00
dtourolle 7a719ee4ac Feature: All bussiness units get a tile
🏗️ Build Plugin / build (push) Successful in 43s
Latest Release / latest-release (push) Successful in 42s
🧪 Test Plugin / test (push) Successful in 29s
2026-06-27 11:00:07 +02:00
dtourolle 5667c2e12f Cache manageent for bussines units 2026-06-27 09:34:07 +02:00
Gitea Actions fef1bd6b17 Update manifest.json for version 1.0.32 2026-06-27 07:16:38 +00:00
Gitea Actions 763217c43d Update manifest.json for latest build (c2feaae) 2026-06-27 07:15:27 +00:00
dtourolle c2feaae89c Fix really fix redirect
🏗️ Build Plugin / build (push) Successful in 29s
Latest Release / latest-release (push) Successful in 47s
🧪 Test Plugin / test (push) Successful in 28s
🚀 Release Plugin / build-and-release (push) Successful in 43s
2026-06-27 09:14:07 +02:00
Gitea Actions 35b103c056 Update manifest.json for version 1.0.31 2026-06-27 06:54:02 +00:00
Gitea Actions 7af1d9b8f4 Update manifest.json for latest build (10a76f8) 2026-06-27 06:52:52 +00:00
dtourolle 10a76f8d45 Fix settings no longer redirect.
🏗️ Build Plugin / build (push) Successful in 1m16s
Latest Release / latest-release (push) Successful in 42s
🧪 Test Plugin / test (push) Successful in 27s
🚀 Release Plugin / build-and-release (push) Successful in 42s
2026-06-27 08:50:49 +02:00
Gitea Actions a39a10636b Update manifest.json for version 1.0.30 2026-05-03 16:45:02 +00:00
Gitea Actions 029fb78049 Update manifest.json for latest build (729f82b) 2026-05-03 16:39:53 +00:00
dtourolle 729f82b26d More tizen based changes
🏗️ Build Plugin / build (push) Successful in 56s
Latest Release / latest-release (push) Successful in 2m28s
🧪 Test Plugin / test (push) Successful in 52s
🚀 Release Plugin / build-and-release (push) Successful in 1m1s
2026-05-03 18:36:26 +02:00
Gitea Actions aad0c21d4b Update manifest.json for version 1.0.29 2026-05-03 16:07:24 +00:00
Gitea Actions 7a52b3da3b Update manifest.json for latest build (80598ea) 2026-05-03 16:05:38 +00:00
dtourolle 80598ea8cb Fix CI
🏗️ Build Plugin / build (push) Successful in 2m40s
Latest Release / latest-release (push) Successful in 1m22s
🧪 Test Plugin / test (push) Successful in 46s
🚀 Release Plugin / build-and-release (push) Successful in 1m1s
2026-05-03 17:10:46 +02:00
dtourolle 24f53ef13e Add support for Tizen via transcoding
🏗️ Build Plugin / build (push) Failing after 15m44s
Latest Release / latest-release (push) Has been cancelled
🧪 Test Plugin / test (push) Has been cancelled
🚀 Release Plugin / build-and-release (push) Failing after 3m23s
2026-05-03 10:41:27 +02:00
Gitea Actions 86b22c69d7 Update manifest.json for latest build (8ffa0a0) 2026-03-07 17:41:15 +00:00
dtourolle 8ffa0a0f76 Add control page for downloads
🏗️ Build Plugin / build (push) Successful in 44s
Latest Release / latest-release (push) Successful in 53s
🧪 Test Plugin / test (push) Successful in 36s
2026-03-07 18:39:32 +01:00
Gitea Actions 92f315f6f6 Update manifest.json for version 1.0.27 2026-03-07 16:40:18 +00:00
Gitea Actions aa1ccbe458 Update manifest.json for latest build (702a5ab)
🚀 Release Plugin / build-and-release (push) Successful in 44s
2026-03-07 16:28:38 +00:00
dtourolle 702a5abdc5 fix time zone mix.up
🏗️ Build Plugin / build (push) Successful in 44s
Latest Release / latest-release (push) Successful in 53s
🧪 Test Plugin / test (push) Successful in 36s
2026-03-07 17:26:59 +01:00
Gitea Actions cdab2d76a7 Update manifest.json for latest build (9202ab6) 2026-03-07 15:59:58 +00:00
dtourolle 9202ab62ed fix CI
🏗️ Build Plugin / build (push) Successful in 45s
Latest Release / latest-release (push) Successful in 52s
🧪 Test Plugin / test (push) Successful in 39s
2026-03-07 16:58:13 +01:00
dtourolle 1b23e203ce CI fix
🏗️ Build Plugin / build (push) Successful in 45s
Latest Release / latest-release (push) Failing after 53s
🧪 Test Plugin / test (push) Successful in 36s
2026-03-07 16:39:38 +01:00
dtourolle 611fb52d76 CI fix
🏗️ Build Plugin / build (push) Successful in 44s
Latest Release / latest-release (push) Failing after 52s
🧪 Test Plugin / test (push) Successful in 37s
2026-03-07 16:35:12 +01:00
dtourolle 3336bac3fb latest make jeelyfin release
🏗️ Build Plugin / build (push) Successful in 46s
Latest Release / latest-release (push) Failing after 50s
🧪 Test Plugin / test (push) Successful in 37s
2026-03-07 16:31:53 +01:00
dtourolle 462c8a7c7b latest build pipeline 2026-03-07 16:29:57 +01:00
dtourolle 0a2d6a558c no dupplicate recordings
🏗️ Build Plugin / build (push) Failing after 28s
🧪 Test Plugin / test (push) Failing after 39s
🚀 Release Plugin / build-and-release (push) Failing after 38s
2026-03-07 16:25:01 +01:00
dtourolle fb539d6a32 Scheduling fixes 2026-03-07 16:24:29 +01:00
Gitea Actions 6ba5df6be9 Update manifest.json for version 1.0.25 2026-03-07 15:11:52 +00:00
dtourolle 33209c3b33 Fix bug in recoridng page
🏗️ Build Plugin / build (push) Successful in 43s
🧪 Test Plugin / test (push) Successful in 36s
🚀 Release Plugin / build-and-release (push) Successful in 52s
2026-03-07 16:09:37 +01:00
Gitea Actions 2177ef9814 Update manifest.json for version 1.0.24 2026-03-07 14:55:13 +00:00
dtourolle 87bdec280b Add recording of livestreams
🏗️ Build Plugin / build (push) Successful in 46s
🧪 Test Plugin / test (push) Successful in 37s
🚀 Release Plugin / build-and-release (push) Successful in 53s
2026-03-07 15:52:51 +01:00
Gitea Actions 5f6e928409 Update manifest.json for version 1.0.23 2026-02-28 12:13:10 +00:00
dtourolle c1b5dea569 Fixes for Livestreams
🏗️ Build Plugin / build (push) Successful in 41s
🧪 Test Plugin / test (push) Successful in 34s
🚀 Release Plugin / build-and-release (push) Successful in 51s
2026-02-28 13:11:02 +01:00
Gitea Actions 2879ebf4df Update manifest.json for version 1.0.22 2026-02-28 11:36:30 +00:00
dtourolle b0cdcda900 CI fix
🏗️ Build Plugin / build (push) Successful in 44s
🧪 Test Plugin / test (push) Successful in 35s
🚀 Release Plugin / build-and-release (push) Successful in 52s
2026-02-28 12:34:15 +01:00
dtourolle 5fadf5a9a7 CI fix
🏗️ Build Plugin / build (push) Successful in 43s
🧪 Test Plugin / test (push) Successful in 35s
🚀 Release Plugin / build-and-release (push) Failing after 54s
2026-02-28 12:29:27 +01:00
dtourolle a6ae4994e9 CI fix
🏗️ Build Plugin / build (push) Successful in 44s
🧪 Test Plugin / test (push) Successful in 36s
🚀 Release Plugin / build-and-release (push) Failing after 42s
2026-02-28 12:21:35 +01:00
dtourolle 12af9bddb1 CI fix
🏗️ Build Plugin / build (push) Failing after 46s
🧪 Test Plugin / test (push) Successful in 36s
2026-02-28 12:19:14 +01:00
dtourolle 92a840cf42 CI fix
🏗️ Build Plugin / build (push) Failing after 27s
🧪 Test Plugin / test (push) Failing after 41s
2026-02-28 12:17:48 +01:00
dtourolle 9210532cf1 CI fix
🏗️ Build Plugin / build (push) Failing after 42s
🧪 Test Plugin / test (push) Successful in 52s
2026-02-28 12:14:41 +01:00
dtourolle 66d5faead2 build system cleans before build
🏗️ Build Plugin / build (push) Failing after 37s
🧪 Test Plugin / test (push) Successful in 48s
2026-02-28 12:12:37 +01:00
dtourolle 65c7a16cee Add missing deps
🏗️ Build Plugin / build (push) Failing after 1s
🧪 Test Plugin / test (push) Failing after 2s
2026-02-28 12:09:22 +01:00
dtourolle 46684402e6 Use docker on CI
🚀 Release Plugin / build-and-release (push) Failing after 2s
🧪 Test Plugin / test (push) Failing after 50s
🏗️ Build Plugin / build (push) Failing after 50s
2026-02-28 12:05:41 +01:00
dtourolle d75e69249b Live-stream starts 6 seconds from edge not at oldest segment.
🧪 Test Plugin / test (push) Failing after 5s
🚀 Release Plugin / build-and-release (push) Failing after 4s
🏗️ Build Plugin / build (push) Successful in 2m55s
2026-02-28 11:55:05 +01:00
dtourolle 7c76402a4a Clean up dead code, consolidate duplication, fix redundancies
🏗️ Build Plugin / build (push) Failing after 9s
🧪 Test Plugin / test (push) Successful in 1m28s
🚀 Release Plugin / build-and-release (push) Failing after 5s
Remove 9 dead methods, 6 unused constants, and redundant
ReaderWriterLockSlim from MetadataCache. Consolidate repeated
patterns into HasChapters, IsPlayable, and ToLowerString helpers.
Extract shared API methods in SRFApiClient. Move variant manifest
rewriting from controller to StreamProxyService. Make Auto quality
distinct from HD. Update README architecture section.
2026-02-28 11:34:45 +01:00
Gitea Actions 873e531599 Update manifest.json for version 1.0.16 2026-01-17 20:31:09 +00:00
dtourolle 5c898a98f5 Use place holders for channel titles
🏗️ Build Plugin / build (push) Successful in 2m56s
🧪 Test Plugin / test (push) Successful in 1m25s
🚀 Release Plugin / build-and-release (push) Successful in 2m51s
2026-01-17 21:23:50 +01:00
Gitea Actions c282a98377 Update manifest.json for version 1.0.15 2026-01-17 09:54:39 +00:00
74 changed files with 4292 additions and 852 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
+44 -24
View File
@@ -16,46 +16,66 @@ on:
jobs:
build:
runs-on: ubuntu-latest
container:
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
path: build-${{ github.run_id }}
- name: Verify .NET installation
run: dotnet --version
- name: Cache NuGet packages
uses: actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ hashFiles('**/Jellyfin.Plugin.SRFPlay.csproj', '**/Jellyfin.Plugin.SRFPlay.Tests.csproj') }}
restore-keys: nuget-
- name: Restore dependencies
working-directory: build-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
- name: Compute build version
id: version
run: |
# For PRs, stamp a distinct version so a side-loaded build is
# identifiable in Jellyfin: 1.0.<YYYYMMDD>-pr<N>.<run_number>.
# For plain master pushes, keep a date-based dev version.
DATE=$(date -u +"%Y%m%d")
if [ -n "${{ github.event.pull_request.number }}" ]; then
VERSION="1.0.${DATE}.${{ github.run_number }}"
LABEL="pr${{ github.event.pull_request.number }}"
else
VERSION="1.0.${DATE}.${{ github.run_number }}"
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: Build solution
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained
working-directory: build-${{ github.run_id }}
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained /m:1
- name: Run tests
working-directory: build-${{ github.run_id }}
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal
- name: Install JPRM
run: |
python3 -m venv /tmp/jprm-venv
/tmp/jprm-venv/bin/pip install jprm
- name: Build Jellyfin Plugins
working-directory: build-${{ github.run_id }}
# One zip per Jellyfin generation (see .gitea/scripts/build-plugins.sh).
run: .gitea/scripts/build-plugins.sh "${{ steps.version.outputs.format }}"
- name: Build Jellyfin Plugin
id: jprm
run: |
# Create artifacts directory for JPRM output
mkdir -p artifacts
# Build plugin using JPRM
/tmp/jprm-venv/bin/jprm --verbosity=debug plugin build .
# Find the generated zip file
ARTIFACT=$(find . -name "*.zip" -type f -print -quit)
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT}"
- name: Upload build artifact
- name: Upload build artifacts
uses: actions/upload-artifact@v3
with:
name: jellyfin-srfplay-plugin
path: ${{ steps.jprm.outputs.artifact }}
name: srfplay-${{ steps.version.outputs.label }}-${{ steps.version.outputs.version }}
path: build-${{ github.run_id }}/artifacts/*.zip
retention-days: 30
if-no-files-found: error
- name: Cleanup
if: always()
run: rm -rf build-${{ github.run_id }}
+184
View File
@@ -0,0 +1,184 @@
name: 'Nightly Build'
on:
push:
branches:
- master
paths-ignore:
- '**/*.md'
- 'manifest.json'
- 'manifest-nightly.json'
jobs:
nightly-build:
runs-on: ubuntu-latest
container:
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
path: build-${{ github.run_id }}
- name: Compute nightly version
id: version
run: |
# 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: Restore dependencies
working-directory: build-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
- 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
- name: Run tests
working-directory: build-${{ github.run_id }}
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal
- name: Build Jellyfin Plugins
working-directory: build-${{ github.run_id }}
run: .gitea/scripts/build-plugins.sh "${{ steps.version.outputs.format }}"
- name: Delete existing nightly release
working-directory: build-${{ github.run_id }}
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
REPO_OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}"
GITEA_URL="${{ github.server_url }}"
TAG="latest"
EXISTING=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG}")
if [ "$EXISTING" = "200" ]; then
RELEASE_ID=$(curl -s \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG}" | jq -r '.id')
echo "Deleting existing nightly release (ID: ${RELEASE_ID})..."
curl -s -X DELETE \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}"
fi
curl -s -X DELETE \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/tags/${TAG}" || true
- name: Create nightly release
working-directory: build-${{ github.run_id }}
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
REPO_OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}"
GITEA_URL="${{ github.server_url }}"
VERSION="${{ steps.version.outputs.version }}"
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \
-d "$(jq -n --arg tag "latest" --arg name "Nightly Build ${VERSION}" --arg body "SRFPlay Jellyfin Plugin nightly build ${VERSION} from master (${SHORT_SHA})." '{tag_name: $tag, name: $name, body: $body, target_commitish: "master", draft: false, prerelease: true}')")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
RELEASE_ID=$(echo "$BODY" | jq -r '.id')
echo "Created release with ID: ${RELEASE_ID}"
else
echo "Failed to create release. HTTP ${HTTP_CODE}"
echo "$BODY"
exit 1
fi
# 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..."
curl -f -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/x-yaml" \
--data-binary "@build.yaml" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=build.yaml"
echo "Nightly release updated successfully!"
- name: Update manifest-nightly.json
working-directory: build-${{ github.run_id }}
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
REPO_OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}"
GITEA_URL="${{ github.server_url }}"
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
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
# -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
# 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 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: ${{ steps.version.outputs.version }} (${SHORT_SHA})" || echo "No changes to commit"
git push origin master
- name: Cleanup
if: always()
run: rm -rf build-${{ github.run_id }}
+51 -70
View File
@@ -14,13 +14,14 @@ on:
jobs:
build-and-release:
runs-on: ubuntu-latest
container:
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Verify .NET installation
run: dotnet --version
with:
path: release-${{ github.run_id }}
- name: Get version
id: get_version
@@ -34,50 +35,33 @@ jobs:
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
echo "Building version: ${VERSION}"
- name: Update build.yaml with version
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:
path: ~/.nuget/packages
key: nuget-${{ hashFiles('**/Jellyfin.Plugin.SRFPlay.csproj', '**/Jellyfin.Plugin.SRFPlay.Tests.csproj') }}
restore-keys: nuget-
- name: Restore dependencies
working-directory: release-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
- name: Build solution
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained
working-directory: release-${{ github.run_id }}
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained /m:1
- name: Run tests
working-directory: release-${{ github.run_id }}
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal
- name: Install JPRM
run: |
python3 -m venv /tmp/jprm-venv
/tmp/jprm-venv/bin/pip install jprm
- name: Build Jellyfin Plugin
id: jprm
run: |
# Create artifacts directory for JPRM output
mkdir -p artifacts
# Build plugin using JPRM
/tmp/jprm-venv/bin/jprm --verbosity=debug plugin build ./
# Find the generated zip file
ARTIFACT=$(find . -name "*.zip" -type f -print -quit)
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
run: |
CHECKSUM=$(md5sum "${{ steps.jprm.outputs.artifact }}" | awk '{print $1}')
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
echo "Checksum: ${CHECKSUM}"
- name: Build Jellyfin Plugins
working-directory: release-${{ github.run_id }}
# 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 }}
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
@@ -86,22 +70,13 @@ jobs:
REPO_NAME="${{ github.event.repository.name }}"
GITEA_URL="${{ github.server_url }}"
# Prepare release body
RELEASE_BODY="SRFPlay Jellyfin Plugin ${{ steps.get_version.outputs.version }}\n\nSee attached files for plugin installation."
RELEASE_BODY_JSON=$(echo -n "${RELEASE_BODY}" | jq -Rs .)
# Create release using Gitea API
VERSION="${{ steps.get_version.outputs.version }}"
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \
-d "{
\"tag_name\": \"${{ steps.get_version.outputs.version }}\",
\"name\": \"Release ${{ steps.get_version.outputs.version }}\",
\"body\": ${RELEASE_BODY_JSON},
\"draft\": false,
\"prerelease\": false
}")
-d "$(jq -n --arg tag "$VERSION" --arg name "Release $VERSION" --arg body "SRFPlay Jellyfin Plugin. See attached files for plugin installation." '{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d')
@@ -115,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..."
@@ -135,6 +112,7 @@ jobs:
echo "View at: ${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/tag/${{ steps.get_version.outputs.version }}"
- name: Update manifest.json
working-directory: release-${{ github.run_id }}
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
@@ -142,29 +120,32 @@ 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
- name: Cleanup
if: always()
run: rm -rf release-${{ github.run_id }}
+19 -4
View File
@@ -18,21 +18,32 @@ on:
jobs:
test:
runs-on: ubuntu-latest
container:
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
path: test-${{ github.run_id }}
- name: Verify .NET installation
run: dotnet --version
- name: Cache NuGet packages
uses: actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ hashFiles('**/Jellyfin.Plugin.SRFPlay.csproj', '**/Jellyfin.Plugin.SRFPlay.Tests.csproj') }}
restore-keys: nuget-
- name: Restore dependencies
working-directory: test-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
- name: Build solution
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Debug --no-restore --no-self-contained
working-directory: test-${{ github.run_id }}
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Debug --no-restore --no-self-contained /m:1
- name: Run tests
working-directory: test-${{ github.run_id }}
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Debug --verbosity normal --logger "trx;LogFileName=test-results.trx"
- name: Upload test results
@@ -40,5 +51,9 @@ jobs:
uses: actions/upload-artifact@v3
with:
name: test-results
path: '**/test-results.trx'
path: test-${{ github.run_id }}/**/test-results.trx
retention-days: 7
- name: Cleanup
if: always()
run: rm -rf test-${{ github.run_id }}
+1
View File
@@ -4,3 +4,4 @@ obj/
.idea/
artifacts
*.log
.test-plugin/
+17
View File
@@ -0,0 +1,17 @@
<Project>
<!--
The Jellyfin meta build workflow rewrites Directory.Build.props, stamping Version,
AssemblyVersion and FileVersion all to a date-based value (e.g. 1.0.20260627.182).
The build segment (20260627) exceeds the 16-bit limit (0-65535) that AssemblyVersion
and FileVersion require, which fails the compile (CS7034/CS7035).
Directory.Build.targets is imported AFTER the project (and after Directory.Build.props),
and the workflow does not touch it, so we force valid assembly/file versions here. The
package/plugin manifest Version stays as injected; only the .NET assembly identity is
normalised, which is fine because Jellyfin identifies plugins by GUID + manifest version.
-->
<PropertyGroup>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
</PropertyGroup>
</Project>
+22
View File
@@ -0,0 +1,22 @@
# SRFPlay Builder Image
# Pre-built image with .NET SDK and JPRM for building Jellyfin plugins
# 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:10.0
RUN apt-get update && apt-get install -y \
python3 \
python3-pip \
git \
jq \
nodejs \
npm \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --break-system-packages jprm
WORKDIR /src
+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>
@@ -115,7 +115,7 @@ namespace Jellyfin.Plugin.SRFPlay.Tests
if (chapter.ResourceList != null && chapter.ResourceList.Any())
{
var hlsResource = chapter.ResourceList.FirstOrDefault(r =>
r.Protocol == "HLS" && (r.DrmList == null || r.DrmList.ToString() == "[]"));
r.Protocol == "HLS" && r.IsPlayable);
if (hlsResource != null)
{
@@ -74,7 +74,9 @@ public class Chapter
/// Gets or sets the list of available resources (streams).
/// </summary>
[JsonPropertyName("resourceList")]
public IReadOnlyList<Resource> ResourceList { get; set; } = new List<Resource>();
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "Required for JSON deserialization")]
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA2227:Collection properties should be read only", Justification = "Required for JSON deserialization")]
public List<Resource> ResourceList { get; set; } = new List<Resource>();
/// <summary>
/// Gets or sets the episode number.
@@ -1,5 +1,4 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
@@ -15,6 +14,12 @@ public class MediaComposition
[JsonPropertyName("chapterList")]
public IReadOnlyList<Chapter> ChapterList { get; set; } = new List<Chapter>();
/// <summary>
/// Gets a value indicating whether this composition has any chapters.
/// </summary>
[JsonIgnore]
public bool HasChapters => ChapterList != null && ChapterList.Count > 0;
/// <summary>
/// Gets or sets the episode information.
/// </summary>
@@ -0,0 +1,101 @@
using System;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
/// <summary>
/// A scheduled or active recording.
/// </summary>
public class RecordingEntry
{
/// <summary>
/// Gets or sets the unique recording ID.
/// </summary>
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the SRF URN.
/// </summary>
[JsonPropertyName("urn")]
public string Urn { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the business unit this recording belongs to (e.g. "srf", "rts").
/// Used to show recordings under the matching unit's channel.
/// </summary>
[JsonPropertyName("businessUnit")]
public string BusinessUnit { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the title.
/// </summary>
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the description.
/// </summary>
[JsonPropertyName("description")]
public string? Description { get; set; }
/// <summary>
/// Gets or sets the image URL.
/// </summary>
[JsonPropertyName("imageUrl")]
public string? ImageUrl { get; set; }
/// <summary>
/// Gets or sets when the livestream starts.
/// </summary>
[JsonPropertyName("validFrom")]
public DateTime? ValidFrom { get; set; }
/// <summary>
/// Gets or sets when the livestream ends.
/// </summary>
[JsonPropertyName("validTo")]
public DateTime? ValidTo { get; set; }
/// <summary>
/// Gets or sets the recording state.
/// </summary>
[JsonPropertyName("state")]
public RecordingState State { get; set; } = RecordingState.Scheduled;
/// <summary>
/// Gets or sets the output file path.
/// </summary>
[JsonPropertyName("outputPath")]
public string? OutputPath { get; set; }
/// <summary>
/// Gets or sets when the recording actually started.
/// </summary>
[JsonPropertyName("recordingStartedAt")]
public DateTime? RecordingStartedAt { get; set; }
/// <summary>
/// Gets or sets when the recording ended.
/// </summary>
[JsonPropertyName("recordingEndedAt")]
public DateTime? RecordingEndedAt { get; set; }
/// <summary>
/// Gets or sets the file size in bytes.
/// </summary>
[JsonPropertyName("fileSizeBytes")]
public long? FileSizeBytes { get; set; }
/// <summary>
/// Gets or sets the error message if recording failed.
/// </summary>
[JsonPropertyName("errorMessage")]
public string? ErrorMessage { get; set; }
/// <summary>
/// Gets or sets when this entry was created.
/// </summary>
[JsonPropertyName("createdAt")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,25 @@
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
/// <summary>
/// State of a recording.
/// </summary>
public enum RecordingState
{
/// <summary>Scheduled for future recording.</summary>
Scheduled,
/// <summary>Waiting for stream to become available.</summary>
WaitingForStream,
/// <summary>Currently recording.</summary>
Recording,
/// <summary>Recording completed successfully.</summary>
Completed,
/// <summary>Recording failed.</summary>
Failed,
/// <summary>Recording was cancelled.</summary>
Cancelled
}
@@ -2,6 +2,9 @@ using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
// NOTE: DrmList is typed as object? because the SRF API returns either null or a JSON array.
// IsPlayable checks for both null and empty array ("[]") to determine if content is DRM-free.
/// <summary>
/// Represents a streaming resource (URL) in the SRF API response.
/// </summary>
@@ -48,4 +51,10 @@ public class Resource
/// </summary>
[JsonPropertyName("drmList")]
public object? DrmList { get; set; }
/// <summary>
/// Gets a value indicating whether this resource is playable (not DRM-protected).
/// </summary>
[JsonIgnore]
public bool IsPlayable => DrmList == null || DrmList.ToString() == "[]";
}
@@ -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>();
}
+42 -104
View File
@@ -20,7 +20,6 @@ namespace Jellyfin.Plugin.SRFPlay.Api;
/// </summary>
public class SRFApiClient : IDisposable
{
private static readonly System.Text.CompositeFormat PlayV3UrlFormat = System.Text.CompositeFormat.Parse(ApiEndpoints.PlayV3BaseUrlTemplate);
private readonly HttpClient _httpClient;
private readonly HttpClient _playV3HttpClient;
private readonly ILogger _logger;
@@ -58,6 +57,21 @@ public class SRFApiClient : IDisposable
};
}
/// <summary>
/// Builds the Play v3 API base URL for a business unit. The host is normally
/// www.&lt;unit&gt;.ch, but SWI's site lives at www.swissinfo.ch (www.swi.ch is an
/// unrelated welding institute), while the API path segment stays the unit code.
/// </summary>
/// <param name="businessUnit">The lowercase business unit (e.g. "srf", "swi").</param>
/// <returns>The Play v3 production base URL ending in a slash.</returns>
private static string BuildPlayV3BaseUrl(string businessUnit)
{
var host = string.Equals(businessUnit, "swi", StringComparison.OrdinalIgnoreCase)
? "www.swissinfo.ch"
: $"www.{businessUnit}.ch";
return $"https://{host}/play/v3/api/{businessUnit}/production/";
}
/// <summary>
/// Reads HTTP response content as UTF-8 string.
/// </summary>
@@ -228,7 +242,7 @@ public class SRFApiClient : IDisposable
var result = JsonSerializer.Deserialize<MediaComposition>(output, _jsonOptions);
if (result?.ChapterList != null && result.ChapterList.Count > 0)
if (result?.HasChapters == true)
{
_logger.LogInformation("Successfully fetched media composition via curl - Chapters: {ChapterCount}", result.ChapterList.Count);
}
@@ -248,38 +262,8 @@ public class SRFApiClient : IDisposable
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The media composition containing latest videos.</returns>
public async Task<MediaComposition?> GetLatestVideosAsync(string businessUnit, CancellationToken cancellationToken = default)
{
try
{
var url = $"/video/{businessUnit}/latest.json";
_logger.LogInformation("Fetching latest videos for business unit: {BusinessUnit} from URL: {Url}", businessUnit, url);
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Latest videos API response: {StatusCode}", response.StatusCode);
if (!response.IsSuccessStatusCode)
{
var errorContent = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
return null;
}
var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Latest videos response length: {Length}", content.Length);
var result = JsonSerializer.Deserialize<MediaComposition>(content, _jsonOptions);
_logger.LogInformation("Successfully fetched latest videos for business unit: {BusinessUnit}", businessUnit);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching latest videos for business unit: {BusinessUnit}", businessUnit);
return null;
}
}
public Task<MediaComposition?> GetLatestVideosAsync(string businessUnit, CancellationToken cancellationToken = default)
=> GetMediaCompositionListAsync(businessUnit, "latest", cancellationToken);
/// <summary>
/// Gets the trending videos for a business unit.
@@ -287,16 +271,19 @@ public class SRFApiClient : IDisposable
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The media composition containing trending videos.</returns>
public async Task<MediaComposition?> GetTrendingVideosAsync(string businessUnit, CancellationToken cancellationToken = default)
public Task<MediaComposition?> GetTrendingVideosAsync(string businessUnit, CancellationToken cancellationToken = default)
=> GetMediaCompositionListAsync(businessUnit, "trending", cancellationToken);
private async Task<MediaComposition?> GetMediaCompositionListAsync(string businessUnit, string endpoint, CancellationToken cancellationToken)
{
try
{
var url = $"/video/{businessUnit}/trending.json";
_logger.LogInformation("Fetching trending videos for business unit: {BusinessUnit} from URL: {Url}", businessUnit, url);
var url = $"/video/{businessUnit}/{endpoint}.json";
_logger.LogInformation("Fetching {Endpoint} videos for business unit: {BusinessUnit} from URL: {Url}", endpoint, businessUnit, url);
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Trending videos API response: {StatusCode}", response.StatusCode);
_logger.LogInformation("{Endpoint} videos API response: {StatusCode}", endpoint, response.StatusCode);
if (!response.IsSuccessStatusCode)
{
@@ -306,41 +293,16 @@ public class SRFApiClient : IDisposable
}
var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Trending videos response length: {Length}", content.Length);
_logger.LogDebug("{Endpoint} videos response length: {Length}", endpoint, content.Length);
var result = JsonSerializer.Deserialize<MediaComposition>(content, _jsonOptions);
_logger.LogInformation("Successfully fetched trending videos for business unit: {BusinessUnit}", businessUnit);
_logger.LogInformation("Successfully fetched {Endpoint} videos for business unit: {BusinessUnit}", endpoint, businessUnit);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching trending videos for business unit: {BusinessUnit}", businessUnit);
return null;
}
}
/// <summary>
/// Gets raw JSON response from a URL.
/// </summary>
/// <param name="url">The relative URL.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The JSON string.</returns>
public async Task<string?> GetJsonAsync(string url, CancellationToken cancellationToken = default)
{
try
{
_logger.LogDebug("Fetching JSON from URL: {Url}", url);
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
return content;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching JSON from URL: {Url}", url);
_logger.LogError(ex, "Error fetching {Endpoint} videos for business unit: {BusinessUnit}", endpoint, businessUnit);
return null;
}
}
@@ -351,35 +313,8 @@ public class SRFApiClient : IDisposable
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of shows.</returns>
public async Task<System.Collections.Generic.List<PlayV3Show>?> GetAllShowsAsync(string businessUnit, CancellationToken cancellationToken = default)
{
try
{
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
var url = $"{baseUrl}shows";
_logger.LogInformation("Fetching all shows for business unit: {BusinessUnit} from URL: {Url}", businessUnit, url);
var response = await _playV3HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errorContent = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
return null;
}
var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
var result = JsonSerializer.Deserialize<PlayV3DirectResponse<PlayV3Show>>(content, _jsonOptions);
_logger.LogInformation("Successfully fetched {Count} shows for business unit: {BusinessUnit}", result?.Data?.Count ?? 0, businessUnit);
return result?.Data;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching shows for business unit: {BusinessUnit}", businessUnit);
return null;
}
}
public Task<System.Collections.Generic.List<PlayV3Show>?> GetAllShowsAsync(string businessUnit, CancellationToken cancellationToken = default)
=> GetPlayV3DirectListAsync<PlayV3Show>(businessUnit, "shows", cancellationToken);
/// <summary>
/// Gets all topics from the Play v3 API.
@@ -387,13 +322,16 @@ public class SRFApiClient : IDisposable
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of topics.</returns>
public async Task<System.Collections.Generic.List<PlayV3Topic>?> GetAllTopicsAsync(string businessUnit, CancellationToken cancellationToken = default)
public Task<System.Collections.Generic.List<PlayV3Topic>?> GetAllTopicsAsync(string businessUnit, CancellationToken cancellationToken = default)
=> GetPlayV3DirectListAsync<PlayV3Topic>(businessUnit, "topics", cancellationToken);
private async Task<System.Collections.Generic.List<T>?> GetPlayV3DirectListAsync<T>(string businessUnit, string endpoint, CancellationToken cancellationToken)
{
try
{
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
var url = $"{baseUrl}topics";
_logger.LogInformation("Fetching all topics for business unit: {BusinessUnit} from URL: {Url}", businessUnit, url);
var baseUrl = BuildPlayV3BaseUrl(businessUnit);
var url = $"{baseUrl}{endpoint}";
_logger.LogInformation("Fetching all {Endpoint} for business unit: {BusinessUnit} from URL: {Url}", endpoint, businessUnit, url);
var response = await _playV3HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
@@ -405,14 +343,14 @@ public class SRFApiClient : IDisposable
}
var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
var result = JsonSerializer.Deserialize<PlayV3DirectResponse<PlayV3Topic>>(content, _jsonOptions);
var result = JsonSerializer.Deserialize<PlayV3DirectResponse<T>>(content, _jsonOptions);
_logger.LogInformation("Successfully fetched {Count} topics for business unit: {BusinessUnit}", result?.Data?.Count ?? 0, businessUnit);
_logger.LogInformation("Successfully fetched {Count} {Endpoint} for business unit: {BusinessUnit}", result?.Data?.Count ?? 0, endpoint, businessUnit);
return result?.Data;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching topics for business unit: {BusinessUnit}", businessUnit);
_logger.LogError(ex, "Error fetching {Endpoint} for business unit: {BusinessUnit}", endpoint, businessUnit);
return null;
}
}
@@ -428,7 +366,7 @@ public class SRFApiClient : IDisposable
{
try
{
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
var baseUrl = BuildPlayV3BaseUrl(businessUnit);
var url = $"{baseUrl}videos-by-show-id?showId={showId}";
_logger.LogDebug("Fetching videos for show {ShowId} from business unit: {BusinessUnit}", showId, businessUnit);
@@ -468,7 +406,7 @@ public class SRFApiClient : IDisposable
{
try
{
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
var baseUrl = BuildPlayV3BaseUrl(businessUnit);
var url = $"{baseUrl}livestreams?eventType={eventType.ToUpperInvariant()}";
_logger.LogInformation("Fetching scheduled livestreams for eventType={EventType} from business unit: {BusinessUnit}", eventType, businessUnit);
@@ -0,0 +1,43 @@
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Channels;
/// <summary>
/// Radiotelevisione svizzera — Swiss Italian channel.
/// </summary>
public sealed class RsiChannel : SrgChannelBase
{
/// <summary>
/// Initializes a new instance of the <see cref="RsiChannel"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="contentRefreshService">The content refresh service.</param>
/// <param name="streamResolver">The stream resolver.</param>
/// <param name="mediaSourceFactory">The media source factory.</param>
/// <param name="categoryService">The category service.</param>
/// <param name="apiClientFactory">The API client factory.</param>
/// <param name="recordingService">The recording service.</param>
public RsiChannel(
ILoggerFactory loggerFactory,
IContentRefreshService contentRefreshService,
IStreamUrlResolver streamResolver,
IMediaSourceFactory mediaSourceFactory,
ICategoryService? categoryService,
ISRFApiClientFactory apiClientFactory,
IRecordingService recordingService)
: base(loggerFactory, contentRefreshService, streamResolver, mediaSourceFactory, categoryService, apiClientFactory, recordingService)
{
}
/// <inheritdoc />
protected override BusinessUnit Unit => BusinessUnit.RSI;
/// <inheritdoc />
protected override string LogoResourceName => "Jellyfin.Plugin.SRFPlay.Images.rsi-logo.png";
/// <inheritdoc />
public override string Description => "Radiotelevisione svizzera — Swiss Italian video-on-demand content";
}
@@ -0,0 +1,43 @@
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Channels;
/// <summary>
/// Radiotelevisiun Svizra Rumantscha — Swiss Romansh channel.
/// </summary>
public sealed class RtrChannel : SrgChannelBase
{
/// <summary>
/// Initializes a new instance of the <see cref="RtrChannel"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="contentRefreshService">The content refresh service.</param>
/// <param name="streamResolver">The stream resolver.</param>
/// <param name="mediaSourceFactory">The media source factory.</param>
/// <param name="categoryService">The category service.</param>
/// <param name="apiClientFactory">The API client factory.</param>
/// <param name="recordingService">The recording service.</param>
public RtrChannel(
ILoggerFactory loggerFactory,
IContentRefreshService contentRefreshService,
IStreamUrlResolver streamResolver,
IMediaSourceFactory mediaSourceFactory,
ICategoryService? categoryService,
ISRFApiClientFactory apiClientFactory,
IRecordingService recordingService)
: base(loggerFactory, contentRefreshService, streamResolver, mediaSourceFactory, categoryService, apiClientFactory, recordingService)
{
}
/// <inheritdoc />
protected override BusinessUnit Unit => BusinessUnit.RTR;
/// <inheritdoc />
protected override string LogoResourceName => "Jellyfin.Plugin.SRFPlay.Images.rtr-logo.png";
/// <inheritdoc />
public override string Description => "Radiotelevisiun Svizra Rumantscha — Swiss Romansh video-on-demand content";
}
@@ -0,0 +1,43 @@
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Channels;
/// <summary>
/// Radio Télévision Suisse — Swiss French channel.
/// </summary>
public sealed class RtsChannel : SrgChannelBase
{
/// <summary>
/// Initializes a new instance of the <see cref="RtsChannel"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="contentRefreshService">The content refresh service.</param>
/// <param name="streamResolver">The stream resolver.</param>
/// <param name="mediaSourceFactory">The media source factory.</param>
/// <param name="categoryService">The category service.</param>
/// <param name="apiClientFactory">The API client factory.</param>
/// <param name="recordingService">The recording service.</param>
public RtsChannel(
ILoggerFactory loggerFactory,
IContentRefreshService contentRefreshService,
IStreamUrlResolver streamResolver,
IMediaSourceFactory mediaSourceFactory,
ICategoryService? categoryService,
ISRFApiClientFactory apiClientFactory,
IRecordingService recordingService)
: base(loggerFactory, contentRefreshService, streamResolver, mediaSourceFactory, categoryService, apiClientFactory, recordingService)
{
}
/// <inheritdoc />
protected override BusinessUnit Unit => BusinessUnit.RTS;
/// <inheritdoc />
protected override string LogoResourceName => "Jellyfin.Plugin.SRFPlay.Images.rts-logo.png";
/// <inheritdoc />
public override string Description => "Radio Télévision Suisse — Swiss French video-on-demand content";
}
@@ -0,0 +1,43 @@
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Channels;
/// <summary>
/// Schweizer Radio und Fernsehen — Swiss German channel.
/// </summary>
public sealed class SrfChannel : SrgChannelBase
{
/// <summary>
/// Initializes a new instance of the <see cref="SrfChannel"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="contentRefreshService">The content refresh service.</param>
/// <param name="streamResolver">The stream resolver.</param>
/// <param name="mediaSourceFactory">The media source factory.</param>
/// <param name="categoryService">The category service.</param>
/// <param name="apiClientFactory">The API client factory.</param>
/// <param name="recordingService">The recording service.</param>
public SrfChannel(
ILoggerFactory loggerFactory,
IContentRefreshService contentRefreshService,
IStreamUrlResolver streamResolver,
IMediaSourceFactory mediaSourceFactory,
ICategoryService? categoryService,
ISRFApiClientFactory apiClientFactory,
IRecordingService recordingService)
: base(loggerFactory, contentRefreshService, streamResolver, mediaSourceFactory, categoryService, apiClientFactory, recordingService)
{
}
/// <inheritdoc />
protected override BusinessUnit Unit => BusinessUnit.SRF;
/// <inheritdoc />
protected override string LogoResourceName => "Jellyfin.Plugin.SRFPlay.Images.srf-logo.png";
/// <inheritdoc />
public override string Description => "Schweizer Radio und Fernsehen — Swiss German video-on-demand content";
}
@@ -6,6 +6,8 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
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;
@@ -15,24 +17,27 @@ using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Channels;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Channels;
/// <summary>
/// SRF Play channel for browsing and playing content.
/// Base channel for browsing and playing content from a single SRG SSR business unit.
/// One concrete subclass exists per unit (SRF, RTS, RSI, RTR, SWI), each shown as its own tile.
/// </summary>
public class SRFPlayChannel : IChannel, IHasCacheKey
public abstract class SrgChannelBase : IChannel, IHasCacheKey
{
private readonly ILogger<SRFPlayChannel> _logger;
private readonly ILogger<SrgChannelBase> _logger;
private readonly IContentRefreshService _contentRefreshService;
private readonly IStreamUrlResolver _streamResolver;
private readonly IMediaSourceFactory _mediaSourceFactory;
private readonly ICategoryService? _categoryService;
private readonly ISRFApiClientFactory _apiClientFactory;
private readonly IRecordingService _recordingService;
/// <summary>
/// Initializes a new instance of the <see cref="SRFPlayChannel"/> class.
/// Initializes a new instance of the <see cref="SrgChannelBase"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="contentRefreshService">The content refresh service.</param>
@@ -40,34 +45,47 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
/// <param name="mediaSourceFactory">The media source factory.</param>
/// <param name="categoryService">The category service (optional).</param>
/// <param name="apiClientFactory">The API client factory.</param>
public SRFPlayChannel(
/// <param name="recordingService">The recording service.</param>
protected SrgChannelBase(
ILoggerFactory loggerFactory,
IContentRefreshService contentRefreshService,
IStreamUrlResolver streamResolver,
IMediaSourceFactory mediaSourceFactory,
ICategoryService? categoryService,
ISRFApiClientFactory apiClientFactory)
ISRFApiClientFactory apiClientFactory,
IRecordingService recordingService)
{
_logger = loggerFactory.CreateLogger<SRFPlayChannel>();
_logger = loggerFactory.CreateLogger<SrgChannelBase>();
_contentRefreshService = contentRefreshService;
_streamResolver = streamResolver;
_mediaSourceFactory = mediaSourceFactory;
_categoryService = categoryService;
_apiClientFactory = apiClientFactory;
_recordingService = recordingService;
if (_categoryService == null)
{
_logger.LogWarning("CategoryService not available - category folders will be disabled");
}
_logger.LogDebug("SRFPlayChannel initialized");
_logger.LogDebug("SrgChannelBase initialized");
}
/// <inheritdoc />
public string Name => "SRF Play";
/// <summary>
/// Gets the business unit this channel serves.
/// </summary>
protected abstract BusinessUnit Unit { get; }
/// <summary>
/// Gets the embedded resource name of this unit's logo PNG.
/// </summary>
protected abstract string LogoResourceName { get; }
/// <inheritdoc />
public string Description => "Swiss Radio and Television video-on-demand content";
public string Name => $"{Unit} Play";
/// <inheritdoc />
public abstract string Description { get; }
/// <inheritdoc />
public string DataVersion => "2.0"; // Back to authenticating at channel refresh with auto-refresh for fresh tokens
@@ -108,7 +126,8 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
public Task<DynamicImageResponse> GetChannelImage(ImageType type, CancellationToken cancellationToken)
{
var assembly = GetType().Assembly;
var resourceStream = assembly.GetManifestResourceStream("Jellyfin.Plugin.SRFPlay.Images.logo.png");
var resourceStream = assembly.GetManifestResourceStream(LogoResourceName)
?? assembly.GetManifestResourceStream("Jellyfin.Plugin.SRFPlay.Images.logo.png");
if (resourceStream == null)
{
@@ -170,6 +189,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
"latest" => await GetLatestVideosAsync(cancellationToken).ConfigureAwait(false),
"trending" => await GetTrendingVideosAsync(cancellationToken).ConfigureAwait(false),
"live_sports" => await GetLiveSportsAsync(cancellationToken).ConfigureAwait(false),
"recordings" => GetRecordingItems(),
_ when folderId.StartsWith("category_", StringComparison.Ordinal) => await GetCategoryVideosAsync(folderId, cancellationToken).ConfigureAwait(false),
_ => new List<ChannelItemInfo>()
};
@@ -181,7 +201,8 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
{
CreateFolder("latest", "Latest Videos"),
CreateFolder("trending", "Trending Videos"),
CreateFolder("live_sports", "Live Sports & Events")
CreateFolder("live_sports", "Live Sports & Events"),
CreateFolder("recordings", "Recordings")
};
// Add category folders if enabled
@@ -190,7 +211,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
{
try
{
var businessUnit = config.BusinessUnit.ToString().ToLowerInvariant();
var businessUnit = Unit.ToLowerString();
var topics = await _categoryService.GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
foreach (var topic in topics.Where(t => !string.IsNullOrEmpty(t.Id)))
@@ -200,7 +221,9 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
continue;
}
items.Add(CreateFolder($"category_{topic.Id}", topic.Title ?? topic.Id!, topic.Lead));
// Generate placeholder image for topic
var placeholderUrl = CreatePlaceholderImageUrl(topic.Title ?? topic.Id!, _mediaSourceFactory.GetServerBaseUrl());
items.Add(CreateFolder($"category_{topic.Id}", topic.Title ?? topic.Id!, topic.Lead, placeholderUrl));
}
_logger.LogInformation("Added {Count} category folders", topics.Count);
@@ -214,7 +237,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
return items;
}
private static ChannelItemInfo CreateFolder(string id, string name, string? overview = null)
private static ChannelItemInfo CreateFolder(string id, string name, string? overview = null, string? imageUrl = null)
{
return new ChannelItemInfo
{
@@ -222,31 +245,30 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
Name = name,
Type = ChannelItemType.Folder,
FolderType = ChannelFolderType.Container,
ImageUrl = null,
ImageUrl = imageUrl,
Overview = overview
};
}
private async Task<List<ChannelItemInfo>> GetLatestVideosAsync(CancellationToken cancellationToken)
{
var urns = await _contentRefreshService.RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
var urns = await _contentRefreshService.RefreshLatestContentAsync(Unit.ToLowerString(), cancellationToken).ConfigureAwait(false);
return await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
}
private async Task<List<ChannelItemInfo>> GetTrendingVideosAsync(CancellationToken cancellationToken)
{
var urns = await _contentRefreshService.RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
var urns = await _contentRefreshService.RefreshTrendingContentAsync(Unit.ToLowerString(), cancellationToken).ConfigureAwait(false);
return await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
}
private async Task<List<ChannelItemInfo>> GetLiveSportsAsync(CancellationToken cancellationToken)
{
var items = new List<ChannelItemInfo>();
var config = Plugin.Instance?.Configuration;
try
{
var businessUnit = config?.BusinessUnit.ToString().ToLowerInvariant() ?? "srf";
var businessUnit = Unit.ToLowerString();
using var apiClient = _apiClientFactory.CreateClient();
var scheduledLivestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
@@ -281,8 +303,8 @@ public class SRFPlayChannel : 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();
}
}
}
@@ -294,6 +316,68 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
return items;
}
private List<ChannelItemInfo> GetRecordingItems()
{
var items = new List<ChannelItemInfo>();
var unit = Unit.ToLowerString();
var recordings = _recordingService.GetRecordings(RecordingState.Completed);
foreach (var recording in recordings)
{
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.
if (!string.IsNullOrEmpty(recording.BusinessUnit) &&
!string.Equals(recording.BusinessUnit, unit, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var fileInfo = new System.IO.FileInfo(recording.OutputPath);
var itemId = $"recording_{recording.Id}";
var mediaSource = new MediaSourceInfo
{
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
};
var item = new ChannelItemInfo
{
Id = itemId,
Name = recording.Title,
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()),
MediaSources = new List<MediaSourceInfo> { mediaSource }
};
items.Add(item);
}
_logger.LogInformation("Returning {Count} completed recordings as channel items", items.Count);
return items;
}
private async Task<List<ChannelItemInfo>> GetCategoryVideosAsync(string folderId, CancellationToken cancellationToken)
{
var items = new List<ChannelItemInfo>();
@@ -306,9 +390,8 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
try
{
var config = Plugin.Instance?.Configuration;
var topicId = folderId.Substring("category_".Length);
var businessUnit = config?.BusinessUnit.ToString().ToLowerInvariant() ?? "srf";
var businessUnit = Unit.ToLowerString();
var shows = await _categoryService.GetShowsByTopicAsync(topicId, businessUnit, 20, cancellationToken).ConfigureAwait(false);
var urns = new List<string>();
@@ -409,7 +492,8 @@ public class SRFPlayChannel : 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);
return $"{config?.BusinessUnit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{timeKey}";
var recordingCount = _recordingService.GetRecordings(RecordingState.Completed).Count;
return $"{Unit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{timeKey}_rec{recordingCount}";
}
private async Task<List<ChannelItemInfo>> ConvertUrnsToChannelItems(List<string> urns, CancellationToken cancellationToken)
@@ -438,7 +522,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
_logger.LogDebug("Processing URN: {Urn}", urn);
var mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
if (mediaComposition?.HasChapters != true)
{
_logger.LogWarning("URN {Urn}: No media composition or chapters found", urn);
failedCount++;
@@ -551,8 +635,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
ProviderIds = new Dictionary<string, string>
{
{ "SRF", urn }
},
MediaSources = new List<MediaSourceInfo> { mediaSource }
}
};
// Add series info if available
@@ -0,0 +1,43 @@
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Channels;
/// <summary>
/// SWI swissinfo.ch — Swiss international channel.
/// </summary>
public sealed class SwiChannel : SrgChannelBase
{
/// <summary>
/// Initializes a new instance of the <see cref="SwiChannel"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="contentRefreshService">The content refresh service.</param>
/// <param name="streamResolver">The stream resolver.</param>
/// <param name="mediaSourceFactory">The media source factory.</param>
/// <param name="categoryService">The category service.</param>
/// <param name="apiClientFactory">The API client factory.</param>
/// <param name="recordingService">The recording service.</param>
public SwiChannel(
ILoggerFactory loggerFactory,
IContentRefreshService contentRefreshService,
IStreamUrlResolver streamResolver,
IMediaSourceFactory mediaSourceFactory,
ICategoryService? categoryService,
ISRFApiClientFactory apiClientFactory,
IRecordingService recordingService)
: base(loggerFactory, contentRefreshService, streamResolver, mediaSourceFactory, categoryService, apiClientFactory, recordingService)
{
}
/// <inheritdoc />
protected override BusinessUnit Unit => BusinessUnit.SWI;
/// <inheritdoc />
protected override string LogoResourceName => "Jellyfin.Plugin.SRFPlay.Images.swi-logo.png";
/// <inheritdoc />
public override string Description => "SWI swissinfo.ch — Swiss international video-on-demand content";
}
@@ -75,10 +75,17 @@ 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>
/// Gets or sets the business unit to fetch content from.
/// Gets or sets the legacy single business unit. Retained only for backwards compatibility
/// with older configs; every unit now has its own always-on channel.
/// </summary>
public BusinessUnit BusinessUnit { get; set; }
@@ -156,4 +163,60 @@ public class PluginConfiguration : BasePluginConfiguration
/// When enabled, generates custom thumbnails instead of using SRF-provided images.
/// </summary>
public bool GenerateTitleCards { get; set; }
/// <summary>
/// Gets or sets the output directory for sport livestream recordings.
/// </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
/// 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; }
}
@@ -1,24 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>SRF Play</title>
</head>
<body>
<div id="SRFPlayConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
<div id="SRFPlayConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
<div data-role="content">
<div class="content-primary">
<form id="SRFPlayConfigForm">
<div class="selectContainer">
<label class="selectLabel" for="BusinessUnit">Business Unit</label>
<select is="emby-select" id="BusinessUnit" name="BusinessUnit" class="emby-select-withcolor emby-select">
<option id="optSRF" value="SRF">SRF (German)</option>
<option id="optRTS" value="RTS">RTS (French)</option>
<option id="optRSI" value="RSI">RSI (Italian)</option>
<option id="optRTR" value="RTR">RTR (Romansh)</option>
<option id="optSWI" value="SWI">SWI (International)</option>
</select>
<div class="fieldDescription">Select the Swiss broadcasting unit to fetch content from</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<h3>Channels</h3>
<div class="fieldDescription">Every Swiss broadcaster has its own channel tile: SRF (German), RTS (French), RSI (Italian), RTR (Romansh) and SWI (swissinfo.ch). They are always available — pin the ones you use from the Jellyfin home screen.</div>
</div>
<div class="selectContainer">
<label class="selectLabel" for="QualityPreference">Quality Preference</label>
@@ -90,6 +76,59 @@
<div class="fieldDescription">Password for proxy authentication (leave empty if not required)</div>
</div>
<br />
<h2>Recording Settings</h2>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="RecordingOutputPath">Recording Output Directory</label>
<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" />
<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
&quot;Continue Watching&quot; row forever. Clearing a resume point only resets the
playback position &mdash; 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 &rarr; &quot;Clean Up SRF Play Continue Watching&quot;)</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">
<label class="inputLabel inputLabelUnfocused" for="PublicServerUrl">Public Server URL (Optional)</label>
@@ -106,6 +145,42 @@
</button>
</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>
<h3>Upcoming Sport Livestreams</h3>
<div class="fieldDescription">Select livestreams to record. The recording starts automatically when the stream goes live.</div>
<div id="scheduleContainer" style="margin: 10px 0;">
<p><em>Loading schedule...</em></p>
</div>
<button is="emby-button" type="button" class="raised emby-button" onclick="SRFPlayRecordings.loadSchedule()" style="margin-bottom: 20px;">
<span>Refresh Schedule</span>
</button>
<h3>Scheduled &amp; Active Recordings</h3>
<div id="activeRecordingsContainer" style="margin: 10px 0;">
<p><em>Loading...</em></p>
</div>
<h3>Completed Recordings</h3>
<div id="completedRecordingsContainer" style="margin: 10px 0;">
<p><em>Loading...</em></p>
</div>
<button is="emby-button" type="button" class="raised emby-button" onclick="SRFPlayRecordings.loadRecordings()" style="margin-bottom: 20px;">
<span>Refresh Recordings</span>
</button>
</div>
</div>
<script type="text/javascript">
@@ -117,7 +192,6 @@
.addEventListener('pageshow', function() {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(SRFPlayConfig.pluginUniqueId).then(function (config) {
document.querySelector('#BusinessUnit').value = config.BusinessUnit;
document.querySelector('#QualityPreference').value = config.QualityPreference;
document.querySelector('#ContentRefreshIntervalHours').value = config.ContentRefreshIntervalHours;
document.querySelector('#ExpirationCheckIntervalHours').value = config.ExpirationCheckIntervalHours;
@@ -130,7 +204,19 @@
document.querySelector('#ProxyUsername').value = config.ProxyUsername || '';
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;
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
SRFPlayRecordings.loadSchedule();
SRFPlayRecordings.loadRecordings();
});
});
@@ -138,7 +224,6 @@
.addEventListener('submit', function(e) {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(SRFPlayConfig.pluginUniqueId).then(function (config) {
config.BusinessUnit = document.querySelector('#BusinessUnit').value;
config.QualityPreference = document.querySelector('#QualityPreference').value;
config.ContentRefreshIntervalHours = parseInt(document.querySelector('#ContentRefreshIntervalHours').value);
config.ExpirationCheckIntervalHours = parseInt(document.querySelector('#ExpirationCheckIntervalHours').value);
@@ -151,6 +236,14 @@
config.ProxyUsername = document.querySelector('#ProxyUsername').value;
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;
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);
});
@@ -159,7 +252,238 @@
e.preventDefault();
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 {
'Authorization': 'MediaBrowser Token="' + ApiClient.accessToken() + '"'
};
},
formatDate: function(dateStr) {
if (!dateStr) return 'N/A';
var d = new Date(dateStr);
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
},
formatSize: function(bytes) {
if (!bytes) return '';
if (bytes > 1073741824) return (bytes / 1073741824).toFixed(1) + ' GB';
if (bytes > 1048576) return (bytes / 1048576).toFixed(0) + ' MB';
return (bytes / 1024).toFixed(0) + ' KB';
},
loadSchedule: function() {
var container = document.getElementById('scheduleContainer');
container.innerHTML = '<p><em>Loading schedule...</em></p>';
fetch(this.apiBase + '/Schedule', { headers: this.getHeaders() })
.then(function(r) { return r.json(); })
.then(function(programs) {
if (!programs || programs.length === 0) {
container.innerHTML = '<p>No upcoming sport livestreams found.</p>';
return;
}
var html = '<table style="width:100%; border-collapse: collapse;">';
html += '<thead><tr style="text-align:left; border-bottom: 1px solid #444;">';
html += '<th style="padding: 8px;">Title</th>';
html += '<th style="padding: 8px;">Start</th>';
html += '<th style="padding: 8px;">End</th>';
html += '<th style="padding: 8px;">Action</th>';
html += '</tr></thead><tbody>';
programs.forEach(function(p) {
html += '<tr style="border-bottom: 1px solid #333;">';
html += '<td style="padding: 8px;">' + (p.title || 'Unknown') + '</td>';
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatDate(p.validFrom || p.date) + '</td>';
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatDate(p.validTo) + '</td>';
html += '<td style="padding: 8px;">';
html += '<button is="emby-button" type="button" class="raised emby-button" ';
html += 'onclick="SRFPlayRecordings.scheduleRecording(\'' + encodeURIComponent(p.urn) + '\')">';
html += '<span>Record</span></button>';
html += '</td></tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
})
.catch(function(err) {
container.innerHTML = '<p style="color: #f44;">Error loading schedule: ' + err.message + '</p>';
});
},
scheduleRecording: function(encodedUrn) {
fetch(this.apiBase + '/Schedule/' + encodedUrn, {
method: 'POST',
headers: this.getHeaders()
})
.then(function(r) {
if (r.ok) {
Dashboard.alert('Recording scheduled!');
SRFPlayRecordings.loadRecordings();
} else {
Dashboard.alert('Failed to schedule recording');
}
})
.catch(function(err) { Dashboard.alert('Error: ' + err.message); });
},
loadRecordings: function() {
this.loadActiveRecordings();
this.loadCompletedRecordings();
},
loadActiveRecordings: function() {
var container = document.getElementById('activeRecordingsContainer');
container.innerHTML = '<p><em>Loading...</em></p>';
fetch(this.apiBase + '/All', { headers: this.getHeaders() })
.then(function(r) { return r.json(); })
.then(function(recordings) {
var activeStates = ['Scheduled', 'WaitingForStream', 'Recording', 0, 1, 2];
var active = recordings.filter(function(r) {
return activeStates.indexOf(r.state) !== -1;
});
if (active.length === 0) {
container.innerHTML = '<p>No scheduled or active recordings.</p>';
return;
}
var stateLabels = {'Scheduled': 'Scheduled', 'WaitingForStream': 'Waiting', 'Recording': 'Recording', 'Completed': 'Completed', 'Failed': 'Failed', 'Cancelled': 'Cancelled', 0: 'Scheduled', 1: 'Waiting', 2: 'Recording', 3: 'Completed', 4: 'Failed', 5: 'Cancelled'};
var stateColors = {'Scheduled': '#2196F3', 'WaitingForStream': '#FF9800', 'Recording': '#4CAF50', 'Failed': '#f44336', 'Cancelled': '#9E9E9E', 0: '#2196F3', 1: '#FF9800', 2: '#4CAF50', 4: '#f44336', 5: '#9E9E9E'};
var html = '<table style="width:100%; border-collapse: collapse;">';
html += '<thead><tr style="text-align:left; border-bottom: 1px solid #444;">';
html += '<th style="padding: 8px;">Title</th>';
html += '<th style="padding: 8px;">Status</th>';
html += '<th style="padding: 8px;">Start</th>';
html += '<th style="padding: 8px;">Action</th>';
html += '</tr></thead><tbody>';
active.forEach(function(r) {
html += '<tr style="border-bottom: 1px solid #333;">';
html += '<td style="padding: 8px;">' + (r.title || 'Unknown') + '</td>';
html += '<td style="padding: 8px;"><span style="color:' + (stateColors[r.state] || '#fff') + ';">' + (stateLabels[r.state] || r.state) + '</span></td>';
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatDate(r.validFrom) + '</td>';
html += '<td style="padding: 8px;">';
if (r.state === 2 || r.state === 'Recording') {
html += '<button is="emby-button" type="button" class="raised emby-button" ';
html += 'onclick="SRFPlayRecordings.stopRecording(\'' + r.id + '\')"><span>Stop</span></button>';
} else {
html += '<button is="emby-button" type="button" class="raised emby-button" ';
html += 'onclick="SRFPlayRecordings.cancelRecording(\'' + r.id + '\')"><span>Cancel</span></button>';
}
html += '</td></tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
})
.catch(function(err) {
container.innerHTML = '<p style="color: #f44;">Error: ' + err.message + '</p>';
});
},
loadCompletedRecordings: function() {
var container = document.getElementById('completedRecordingsContainer');
container.innerHTML = '<p><em>Loading...</em></p>';
fetch(this.apiBase + '/Completed', { headers: this.getHeaders() })
.then(function(r) { return r.json(); })
.then(function(recordings) {
if (!recordings || recordings.length === 0) {
container.innerHTML = '<p>No completed recordings.</p>';
return;
}
var html = '<table style="width:100%; border-collapse: collapse;">';
html += '<thead><tr style="text-align:left; border-bottom: 1px solid #444;">';
html += '<th style="padding: 8px;">Title</th>';
html += '<th style="padding: 8px;">Recorded</th>';
html += '<th style="padding: 8px;">Size</th>';
html += '<th style="padding: 8px;">File</th>';
html += '<th style="padding: 8px;">Action</th>';
html += '</tr></thead><tbody>';
recordings.forEach(function(r) {
html += '<tr style="border-bottom: 1px solid #333;">';
html += '<td style="padding: 8px;">' + (r.title || 'Unknown') + '</td>';
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatDate(r.recordingStartedAt) + '</td>';
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatSize(r.fileSizeBytes) + '</td>';
html += '<td style="padding: 8px; font-size: 0.85em; word-break: break-all;">' + (r.outputPath || '') + '</td>';
html += '<td style="padding: 8px;">';
html += '<button is="emby-button" type="button" class="raised emby-button" style="background:#f44336;" ';
html += 'onclick="SRFPlayRecordings.deleteRecording(\'' + r.id + '\')"><span>Delete</span></button>';
html += '</td></tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
})
.catch(function(err) {
container.innerHTML = '<p style="color: #f44;">Error: ' + err.message + '</p>';
});
},
stopRecording: function(id) {
fetch(this.apiBase + '/Active/' + id + '/Stop', {
method: 'POST',
headers: this.getHeaders()
}).then(function() { SRFPlayRecordings.loadRecordings(); });
},
cancelRecording: function(id) {
fetch(this.apiBase + '/Schedule/' + id, {
method: 'DELETE',
headers: this.getHeaders()
}).then(function() { SRFPlayRecordings.loadRecordings(); });
},
deleteRecording: function(id) {
if (!confirm('Delete this recording and its file?')) return;
fetch(this.apiBase + '/Completed/' + id + '?deleteFile=true', {
method: 'DELETE',
headers: this.getHeaders()
}).then(function() { SRFPlayRecordings.loadRecordings(); });
}
};
</script>
</div>
</body>
</html>
@@ -0,0 +1,346 @@
<div id="SRFPlayRecordingsPage" data-role="page" class="page type-interior pluginConfigurationPage"
data-require="emby-button,emby-input">
<style>
#SRFPlayRecordingsPage .srfrec-wrap { padding: 0 1em; }
#SRFPlayRecordingsPage table { width: 100%; border-collapse: collapse; margin: 10px 0 20px; }
#SRFPlayRecordingsPage th { text-align: left; padding: 10px 8px; border-bottom: 2px solid #444; color: #aaa; font-size: 0.85em; text-transform: uppercase; }
#SRFPlayRecordingsPage td { padding: 10px 8px; border-bottom: 1px solid #2a2a3e; }
#SRFPlayRecordingsPage tr:hover { background: rgba(255,255,255,0.04); }
#SRFPlayRecordingsPage .btn { padding: 6px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 0.85em; color: #fff; transition: opacity 0.2s; }
#SRFPlayRecordingsPage .btn:hover { opacity: 0.85; }
#SRFPlayRecordingsPage .btn-record { background: #4CAF50; }
#SRFPlayRecordingsPage .btn-stop { background: #FF9800; }
#SRFPlayRecordingsPage .btn-cancel { background: #9E9E9E; }
#SRFPlayRecordingsPage .btn-delete { background: #f44336; }
#SRFPlayRecordingsPage .btn-refresh { background: #2196F3; margin-bottom: 16px; }
#SRFPlayRecordingsPage .status { padding: 3px 8px; border-radius: 3px; font-size: 0.8em; font-weight: 600; color: #fff; }
#SRFPlayRecordingsPage .status-scheduled { background: #1565C0; }
#SRFPlayRecordingsPage .status-waiting { background: #E65100; }
#SRFPlayRecordingsPage .status-recording { background: #2E7D32; }
#SRFPlayRecordingsPage .status-failed { background: #C62828; }
#SRFPlayRecordingsPage .srfrec-msg { padding: 12px; color: #999; font-style: italic; }
#SRFPlayRecordingsPage .srfrec-error { color: #f44; }
#SRFPlayRecordingsPage .login-form { max-width: 400px; margin: 40px auto; }
#SRFPlayRecordingsPage .login-form input { width: 100%; padding: 10px; margin: 8px 0; background: #2a2a3e; border: 1px solid #444; color: #fff; border-radius: 4px; font-size: 1em; }
#SRFPlayRecordingsPage .login-form .btn { width: 100%; padding: 12px; font-size: 1em; margin-top: 12px; }
</style>
<div data-role="content">
<div class="content-primary srfrec-wrap">
<div id="srfrecLoginSection" style="display:none;">
<div class="login-form">
<h1>SRF Sport Recordings</h1>
<p class="fieldDescription">Sign in to your Jellyfin server</p>
<input type="text" id="srfrecLoginServer" placeholder="Server URL (e.g. http://192.168.1.50:8096)" />
<input type="text" id="srfrecLoginUser" placeholder="Username" />
<input type="password" id="srfrecLoginPass" placeholder="Password" />
<button type="button" class="btn btn-record" onclick="SRFRec.login()">Sign In</button>
<p id="srfrecLoginError" class="srfrec-error" style="margin-top:10px;"></p>
</div>
</div>
<div id="srfrecMainSection" style="display:none;">
<h1>SRF Sport Recordings</h1>
<p class="fieldDescription">Browse upcoming sport livestreams and schedule recordings. <span id="srfrecUserInfo"></span></p>
<h2>Upcoming Sport Livestreams</h2>
<button type="button" class="btn btn-refresh" onclick="SRFRec.loadSchedule()">Refresh Schedule</button>
<div id="srfrecScheduleContainer"><p class="srfrec-msg">Loading schedule...</p></div>
<h2>Scheduled &amp; Active Recordings</h2>
<div id="srfrecActiveRecordingsContainer"><p class="srfrec-msg">Loading...</p></div>
<h2>Completed Recordings</h2>
<button type="button" class="btn btn-refresh" onclick="SRFRec.loadRecordings()">Refresh</button>
<div id="srfrecCompletedRecordingsContainer"><p class="srfrec-msg">Loading...</p></div>
</div>
</div>
</div>
<script>
var SRFRec = {
serverUrl: '',
token: '',
init: function() {
// Check if we're inside Jellyfin's web client (ApiClient available)
if (typeof ApiClient !== 'undefined' && ApiClient.accessToken()) {
this.serverUrl = ApiClient.serverAddress();
this.token = ApiClient.accessToken();
this.showMain();
return;
}
// Check localStorage for saved session
var saved = localStorage.getItem('srfRecSession');
if (saved) {
try {
var session = JSON.parse(saved);
this.serverUrl = session.serverUrl;
this.token = session.token;
// Verify token still works
this.verifySession(session);
return;
} catch(e) { /* fall through to login */ }
}
// Show login form
var serverInput = document.getElementById('srfrecLoginServer');
serverInput.value = window.location.origin;
document.getElementById('srfrecLoginSection').style.display = 'block';
},
verifySession: function(session) {
var self = this;
fetch(this.serverUrl + '/System/Info', {
headers: { 'Authorization': 'MediaBrowser Token="' + this.token + '"' }
}).then(function(r) {
if (r.ok) {
document.getElementById('srfrecUserInfo').textContent = '(Server: ' + self.serverUrl + ')';
self.showMain();
} else {
localStorage.removeItem('srfRecSession');
document.getElementById('srfrecLoginSection').style.display = 'block';
}
}).catch(function() {
localStorage.removeItem('srfRecSession');
document.getElementById('srfrecLoginSection').style.display = 'block';
});
},
login: function() {
var self = this;
var server = document.getElementById('srfrecLoginServer').value.replace(/\/+$/, '');
var user = document.getElementById('srfrecLoginUser').value;
var pass = document.getElementById('srfrecLoginPass').value;
var errorEl = document.getElementById('srfrecLoginError');
errorEl.textContent = '';
fetch(server + '/Users/AuthenticateByName', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'MediaBrowser Client="SRF Recordings", Device="Web", DeviceId="srfrec-' + Date.now() + '", Version="1.0.0"'
},
body: JSON.stringify({ Username: user, Pw: pass })
})
.then(function(r) {
if (!r.ok) throw new Error('Authentication failed');
return r.json();
})
.then(function(data) {
self.serverUrl = server;
self.token = data.AccessToken;
localStorage.setItem('srfRecSession', JSON.stringify({
serverUrl: server,
token: data.AccessToken,
userName: data.User.Name
}));
document.getElementById('srfrecUserInfo').textContent = '(Signed in as ' + data.User.Name + ')';
self.showMain();
})
.catch(function(err) {
errorEl.textContent = err.message;
});
},
showMain: function() {
document.getElementById('srfrecLoginSection').style.display = 'none';
document.getElementById('srfrecMainSection').style.display = 'block';
this.loadSchedule();
this.loadRecordings();
},
getHeaders: function() {
return { 'Authorization': 'MediaBrowser Token="' + this.token + '"' };
},
formatDate: function(dateStr) {
if (!dateStr) return 'N/A';
var d = new Date(dateStr);
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
},
formatSize: function(bytes) {
if (!bytes) return '';
if (bytes > 1073741824) return (bytes / 1073741824).toFixed(1) + ' GB';
if (bytes > 1048576) return (bytes / 1048576).toFixed(0) + ' MB';
return (bytes / 1024).toFixed(0) + ' KB';
},
loadSchedule: function() {
var container = document.getElementById('srfrecScheduleContainer');
container.innerHTML = '<p class="srfrec-msg">Loading schedule...</p>';
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Schedule', { headers: this.getHeaders() })
.then(function(r) { return r.json(); })
.then(function(programs) {
if (!programs || programs.length === 0) {
container.innerHTML = '<p class="srfrec-msg">No upcoming sport livestreams found.</p>';
return;
}
var html = '<table>';
html += '<thead><tr><th>Title</th><th>Start</th><th>End</th><th>Action</th></tr></thead><tbody>';
programs.forEach(function(p) {
html += '<tr>';
html += '<td>' + (p.title || 'Unknown') + '</td>';
html += '<td>' + SRFRec.formatDate(p.validFrom || p.date) + '</td>';
html += '<td>' + SRFRec.formatDate(p.validTo) + '</td>';
html += '<td><button type="button" class="btn btn-record" onclick="SRFRec.scheduleRecording(\'' + encodeURIComponent(p.urn) + '\')">Record</button></td>';
html += '</tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
})
.catch(function(err) {
container.innerHTML = '<p class="srfrec-error">Error loading schedule: ' + err.message + '</p>';
});
},
scheduleRecording: function(encodedUrn) {
var self = this;
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Schedule/' + encodedUrn, {
method: 'POST',
headers: this.getHeaders()
})
.then(function(r) {
if (r.ok) {
alert('Recording scheduled!');
self.loadRecordings();
} else {
alert('Failed to schedule recording');
}
})
.catch(function(err) { alert('Error: ' + err.message); });
},
loadRecordings: function() {
this.loadActiveRecordings();
this.loadCompletedRecordings();
},
loadActiveRecordings: function() {
var container = document.getElementById('srfrecActiveRecordingsContainer');
container.innerHTML = '<p class="srfrec-msg">Loading...</p>';
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/All', { headers: this.getHeaders() })
.then(function(r) { return r.json(); })
.then(function(recordings) {
var activeStates = ['Scheduled', 'WaitingForStream', 'Recording', 0, 1, 2];
var active = recordings.filter(function(r) {
return activeStates.indexOf(r.state) !== -1;
});
if (active.length === 0) {
container.innerHTML = '<p class="srfrec-msg">No scheduled or active recordings.</p>';
return;
}
var stateMap = {
'Scheduled': {label: 'Scheduled', cls: 'status-scheduled'},
'WaitingForStream': {label: 'Waiting', cls: 'status-waiting'},
'Recording': {label: 'Recording', cls: 'status-recording'},
0: {label: 'Scheduled', cls: 'status-scheduled'},
1: {label: 'Waiting', cls: 'status-waiting'},
2: {label: 'Recording', cls: 'status-recording'}
};
var html = '<table>';
html += '<thead><tr><th>Title</th><th>Status</th><th>Start</th><th>Action</th></tr></thead><tbody>';
active.forEach(function(r) {
var st = stateMap[r.state] || {label: r.state, cls: ''};
html += '<tr>';
html += '<td>' + (r.title || 'Unknown') + '</td>';
html += '<td><span class="status ' + st.cls + '">' + st.label + '</span></td>';
html += '<td>' + SRFRec.formatDate(r.validFrom) + '</td>';
html += '<td>';
if (r.state === 2 || r.state === 'Recording') {
html += '<button type="button" class="btn btn-stop" onclick="SRFRec.stopRecording(\'' + r.id + '\')">Stop</button>';
} else {
html += '<button type="button" class="btn btn-cancel" onclick="SRFRec.cancelRecording(\'' + r.id + '\')">Cancel</button>';
}
html += '</td></tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
})
.catch(function(err) {
container.innerHTML = '<p class="srfrec-error">Error: ' + err.message + '</p>';
});
},
loadCompletedRecordings: function() {
var container = document.getElementById('srfrecCompletedRecordingsContainer');
container.innerHTML = '<p class="srfrec-msg">Loading...</p>';
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Completed', { headers: this.getHeaders() })
.then(function(r) { return r.json(); })
.then(function(recordings) {
if (!recordings || recordings.length === 0) {
container.innerHTML = '<p class="srfrec-msg">No completed recordings.</p>';
return;
}
var html = '<table>';
html += '<thead><tr><th>Title</th><th>Recorded</th><th>Size</th><th>Action</th></tr></thead><tbody>';
recordings.forEach(function(r) {
html += '<tr>';
html += '<td>' + (r.title || 'Unknown') + '</td>';
html += '<td>' + SRFRec.formatDate(r.recordingStartedAt) + '</td>';
html += '<td>' + SRFRec.formatSize(r.fileSizeBytes) + '</td>';
html += '<td><button type="button" class="btn btn-delete" onclick="SRFRec.deleteRecording(\'' + r.id + '\')">Delete</button></td>';
html += '</tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
})
.catch(function(err) {
container.innerHTML = '<p class="srfrec-error">Error: ' + err.message + '</p>';
});
},
stopRecording: function(id) {
var self = this;
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Active/' + id + '/Stop', {
method: 'POST',
headers: this.getHeaders()
}).then(function() { self.loadRecordings(); });
},
cancelRecording: function(id) {
var self = this;
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Schedule/' + id, {
method: 'DELETE',
headers: this.getHeaders()
}).then(function() { self.loadRecordings(); });
},
deleteRecording: function(id) {
if (!confirm('Delete this recording and its file?')) return;
var self = this;
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Completed/' + id + '?deleteFile=true', {
method: 'DELETE',
headers: this.getHeaders()
}).then(function() { self.loadRecordings(); });
}
};
(function() {
var page = document.querySelector('#SRFPlayRecordingsPage');
if (page && typeof page.addEventListener === 'function' && typeof ApiClient !== 'undefined') {
// Inside the Jellyfin dashboard: defer to the page lifecycle so we
// never run during the config page's view and never hijack routing.
page.addEventListener('pageshow', function() { SRFRec.init(); });
} else {
// Standalone (e.g. TV/browser): initialise immediately.
SRFRec.init();
}
})();
</script>
</div>
@@ -28,12 +28,6 @@ public static class ApiEndpoints
/// </summary>
public const string SrfPlayHomepage = "https://www.srf.ch/play";
/// <summary>
/// Media composition endpoint template (relative to IntegrationLayerBaseUrl).
/// Format: {0} = URN.
/// </summary>
public const string MediaCompositionByUrnPath = "/mediaComposition/byUrn/{0}.json";
/// <summary>
/// Base proxy route for stream proxying (relative to server root).
/// </summary>
@@ -44,30 +38,4 @@ public static class ApiEndpoints
/// Format: {0} = item ID.
/// </summary>
public const string ProxyMasterManifestPath = "/Plugins/SRFPlay/Proxy/{0}/master.m3u8";
/// <summary>
/// Image proxy route template (relative to server root).
/// Format: {0} = base64-encoded original URL.
/// </summary>
public const string ImageProxyPath = "/Plugins/SRFPlay/Image/{0}";
/// <summary>
/// Play V3 shows endpoint (relative to PlayV3 base URL).
/// </summary>
public const string PlayV3ShowsPath = "shows";
/// <summary>
/// Play V3 topics endpoint (relative to PlayV3 base URL).
/// </summary>
public const string PlayV3TopicsPath = "topics";
/// <summary>
/// Play V3 livestreams endpoint (relative to PlayV3 base URL).
/// </summary>
public const string PlayV3LivestreamsPath = "livestreams";
/// <summary>
/// Play V3 scheduled livestreams endpoint (relative to PlayV3 base URL).
/// </summary>
public const string PlayV3ScheduledLivestreamsPath = "scheduled-livestreams";
}
@@ -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));
}
}
@@ -0,0 +1,175 @@
using System.IO;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Controllers;
/// <summary>
/// Controller for managing sport livestream recordings.
/// </summary>
[ApiController]
[Route("Plugins/SRFPlay/Recording")]
[Authorize]
public class RecordingController : ControllerBase
{
private readonly ILogger<RecordingController> _logger;
private readonly IRecordingService _recordingService;
/// <summary>
/// Initializes a new instance of the <see cref="RecordingController"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="recordingService">The recording service.</param>
public RecordingController(
ILogger<RecordingController> logger,
IRecordingService recordingService)
{
_logger = logger;
_recordingService = recordingService;
}
/// <summary>
/// Serves the recording manager page accessible to any authenticated user.
/// </summary>
/// <returns>The recording manager HTML page.</returns>
[HttpGet("Page")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult GetRecordingPage()
{
var assembly = Assembly.GetExecutingAssembly();
var resourceStream = assembly.GetManifestResourceStream("Jellyfin.Plugin.SRFPlay.Configuration.recordingPage.html");
if (resourceStream == null)
{
return NotFound("Recording page not found");
}
return File(resourceStream, "text/html");
}
/// <summary>
/// Gets upcoming sport livestreams available for recording.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of upcoming livestreams.</returns>
[HttpGet("Schedule")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> GetSchedule(CancellationToken cancellationToken)
{
var schedule = await _recordingService.GetUpcomingScheduleAsync(cancellationToken).ConfigureAwait(false);
return Ok(schedule);
}
/// <summary>
/// Schedules a livestream for recording by URN.
/// </summary>
/// <param name="urn">The SRF URN.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The created recording entry.</returns>
[HttpPost("Schedule/{urn}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> ScheduleRecording(
[FromRoute] string urn,
CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(urn))
{
return BadRequest("URN is required");
}
// URN comes URL-encoded with colons, decode it
urn = System.Net.WebUtility.UrlDecode(urn);
_logger.LogInformation("Scheduling recording for URN: {Urn}", urn);
var entry = await _recordingService.ScheduleRecordingAsync(urn, cancellationToken).ConfigureAwait(false);
return Ok(entry);
}
/// <summary>
/// Cancels a scheduled recording.
/// </summary>
/// <param name="id">The recording ID.</param>
/// <returns>OK or NotFound.</returns>
[HttpDelete("Schedule/{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult CancelRecording([FromRoute] string id)
{
return _recordingService.CancelRecording(id) ? Ok() : NotFound();
}
/// <summary>
/// Gets currently active recordings.
/// </summary>
/// <returns>List of active recordings.</returns>
[HttpGet("Active")]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult GetActiveRecordings()
{
var active = _recordingService.GetRecordings(RecordingState.Recording);
return Ok(active);
}
/// <summary>
/// Stops an active recording.
/// </summary>
/// <param name="id">The recording ID.</param>
/// <returns>OK or NotFound.</returns>
[HttpPost("Active/{id}/Stop")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult StopRecording([FromRoute] string id)
{
return _recordingService.StopRecording(id) ? Ok() : NotFound();
}
/// <summary>
/// Gets completed recordings.
/// </summary>
/// <returns>List of completed recordings.</returns>
[HttpGet("Completed")]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult GetCompletedRecordings()
{
var completed = _recordingService.GetRecordings(RecordingState.Completed);
return Ok(completed);
}
/// <summary>
/// Gets all recordings (all states).
/// </summary>
/// <returns>List of all recordings.</returns>
[HttpGet("All")]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult GetAllRecordings()
{
var all = _recordingService.GetRecordings();
return Ok(all);
}
/// <summary>
/// Deletes a completed recording and its file.
/// </summary>
/// <param name="id">The recording ID.</param>
/// <param name="deleteFile">Whether to delete the file too.</param>
/// <returns>OK or NotFound.</returns>
[HttpDelete("Completed/{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult DeleteRecording(
[FromRoute] string id,
[FromQuery] bool deleteFile = true)
{
return _recordingService.DeleteRecording(id, deleteFile) ? Ok() : NotFound();
}
}
@@ -58,16 +58,28 @@ public class StreamProxyController : ControllerBase
/// Livestreams need frequent manifest refresh, VOD can be cached longer.
/// </summary>
/// <param name="itemId">The item ID to check.</param>
private void AddManifestCacheHeaders(string itemId)
/// <param name="isVariantManifest">Whether this is a variant (sub) manifest with segment lists.</param>
private void AddManifestCacheHeaders(string itemId, bool isVariantManifest = false)
{
var metadata = _proxyService.GetStreamMetadata(itemId);
var isLiveStream = metadata?.IsLiveStream ?? false;
if (isLiveStream)
{
// Livestreams need frequent manifest refresh (segments rotate every ~6-10s)
Response.Headers["Cache-Control"] = "max-age=2, must-revalidate";
_logger.LogDebug("Setting livestream cache headers for {ItemId}", itemId);
if (isVariantManifest)
{
// Variant manifests contain the segment list which changes every target duration.
// Use no-cache to ensure the player always gets the freshest segment list,
// preventing requests for segments that have been rotated out of the sliding window.
Response.Headers["Cache-Control"] = "no-cache, no-store";
}
else
{
// Master manifest is relatively stable (just lists quality variants)
Response.Headers["Cache-Control"] = "max-age=2, must-revalidate";
}
_logger.LogDebug("Setting livestream cache headers for {ItemId} (variant={IsVariant})", itemId, isVariantManifest);
}
else
{
@@ -148,7 +160,8 @@ public class StreamProxyController : ControllerBase
AddManifestCacheHeaders(actualItemId);
_logger.LogDebug("Returning master manifest for item {ItemId} ({Length} bytes)", itemId, manifestContent.Length);
return Content(manifestContent, "application/vnd.apple.mpegurl; charset=utf-8");
// Use application/x-mpegURL for broader compatibility (Samsung AVPlay requires it)
return Content(manifestContent, "application/x-mpegURL; charset=utf-8");
}
catch (Exception ex)
{
@@ -196,13 +209,31 @@ public class StreamProxyController : ControllerBase
var manifestContent = System.Text.Encoding.UTF8.GetString(manifestData);
var scheme = GetProxyScheme();
var baseProxyUrl = $"{scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}";
var rewrittenContent = RewriteSegmentUrls(manifestContent, baseProxyUrl);
// Extract query parameters from the current request to propagate them
string queryParams;
if (Request.Query.TryGetValue("token", out var tokenVal) && !string.IsNullOrEmpty(tokenVal))
{
queryParams = $"?token={tokenVal}";
}
else if (Request.Query.TryGetValue("itemId", out var itemIdVal) && !string.IsNullOrEmpty(itemIdVal))
{
queryParams = $"?itemId={itemIdVal}";
}
else
{
queryParams = string.Empty;
}
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)
AddManifestCacheHeaders(actualItemId);
// Variant manifests use stricter no-cache for live streams
AddManifestCacheHeaders(actualItemId, isVariantManifest: true);
_logger.LogDebug("Returning variant manifest for item {ItemId} ({Length} bytes)", itemId, rewrittenContent.Length);
return Content(rewrittenContent, "application/vnd.apple.mpegurl; charset=utf-8");
return Content(rewrittenContent, "application/x-mpegURL; charset=utf-8");
}
catch (Exception ex)
{
@@ -237,9 +268,12 @@ public class StreamProxyController : ControllerBase
{
// Pass the original query string to preserve segment-specific parameters (e.g., ?m=timestamp)
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : null;
var segmentData = await _proxyService.GetSegmentAsync(actualItemId, segmentPath, queryString, cancellationToken).ConfigureAwait(false);
if (segmentData == null)
// Use streaming proxy: starts forwarding data to the client before the full segment
// is downloaded from the CDN, reducing time-to-first-byte for live streams
using var upstreamResponse = await _proxyService.GetSegmentStreamAsync(actualItemId, segmentPath, queryString, cancellationToken).ConfigureAwait(false);
if (upstreamResponse == null)
{
_logger.LogWarning("Segment not found - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath);
return NotFound();
@@ -247,13 +281,36 @@ public class StreamProxyController : ControllerBase
// Determine content type based on file extension
var contentType = MimeTypeHelper.GetSegmentContentType(segmentPath);
Response.ContentType = contentType;
_logger.LogDebug("Returning segment {SegmentPath} ({Length} bytes, {ContentType})", segmentPath, segmentData.Length, contentType);
return File(segmentData, contentType);
if (upstreamResponse.Content.Headers.ContentLength.HasValue)
{
Response.ContentLength = upstreamResponse.Content.Headers.ContentLength.Value;
}
// Stream directly from CDN to client without buffering the entire segment in memory
await upstreamResponse.Content.CopyToAsync(Response.Body, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Streamed segment {SegmentPath} ({ContentType})", segmentPath, contentType);
return new EmptyResult();
}
catch (OperationCanceledException)
{
// Client disconnected during streaming (e.g., FFmpeg stopped, player seeked).
// This is expected behavior, not an error.
_logger.LogDebug("Segment streaming canceled - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath);
return new EmptyResult();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error proxying segment - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath);
// If we already started streaming data to the client, we can't change the status code
if (Response.HasStarted)
{
return new EmptyResult();
}
return StatusCode(StatusCodes.Status500InternalServerError);
}
}
@@ -311,81 +368,6 @@ public class StreamProxyController : ControllerBase
return pathItemId;
}
/// <summary>
/// Rewrites segment URLs in a manifest to point to proxy.
/// </summary>
/// <param name="manifestContent">The manifest content.</param>
/// <param name="baseProxyUrl">The base proxy URL.</param>
/// <returns>The rewritten manifest.</returns>
private string RewriteSegmentUrls(string manifestContent, string baseProxyUrl)
{
// Extract query parameters from the current request to propagate them
string queryParams;
if (Request.Query.TryGetValue("token", out var token) && !string.IsNullOrEmpty(token))
{
queryParams = $"?token={token}";
}
else if (Request.Query.TryGetValue("itemId", out var itemId) && !string.IsNullOrEmpty(itemId))
{
queryParams = $"?itemId={itemId}";
}
else
{
queryParams = string.Empty;
}
// Helper function to rewrite a single URL
string RewriteUrl(string url)
{
if (url.Contains("://", StringComparison.Ordinal))
{
// Absolute URL - extract filename and rewrite
var uri = new Uri(url.Trim());
var segments = uri.AbsolutePath.Split('/');
var fileName = segments[^1];
return $"{baseProxyUrl}/{fileName}{queryParams}";
}
// Relative URL - rewrite to proxy
return $"{baseProxyUrl}/{url.Trim()}{queryParams}";
}
var lines = manifestContent.Split('\n');
var result = new System.Text.StringBuilder();
foreach (var line in lines)
{
if (string.IsNullOrWhiteSpace(line))
{
result.AppendLine(line);
}
else if (line.StartsWith('#'))
{
// HLS tag line - check for URI="..." attributes (e.g., #EXT-X-MAP:URI="init.mp4")
if (line.Contains("URI=\"", StringComparison.Ordinal))
{
var rewrittenLine = System.Text.RegularExpressions.Regex.Replace(
line,
@"URI=""([^""]+)""",
match => $"URI=\"{RewriteUrl(match.Groups[1].Value)}\"");
result.AppendLine(rewrittenLine);
}
else
{
// Keep other metadata lines as-is
result.AppendLine(line);
}
}
else
{
// Non-tag line with URL - rewrite it
result.AppendLine(RewriteUrl(line));
}
}
return result.ToString();
}
/// <summary>
/// Proxies image requests from SRF CDN, fixing Content-Type headers.
/// </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>
@@ -26,11 +57,18 @@
<ItemGroup>
<None Remove="Configuration\configPage.html" />
<EmbeddedResource Include="Configuration\configPage.html" />
<None Remove="Configuration\recordingPage.html" />
<EmbeddedResource Include="Configuration\recordingPage.html" />
</ItemGroup>
<ItemGroup>
<None Remove="..\assests\main logo.png" />
<EmbeddedResource Include="..\assests\main logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.logo.png" />
<EmbeddedResource Include="..\assests\units\srf-logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.srf-logo.png" />
<EmbeddedResource Include="..\assests\units\rts-logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.rts-logo.png" />
<EmbeddedResource Include="..\assests\units\rsi-logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.rsi-logo.png" />
<EmbeddedResource Include="..\assests\units\rtr-logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.rtr-logo.png" />
<EmbeddedResource Include="..\assests\units\swi-logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.swi-logo.png" />
</ItemGroup>
</Project>
+6
View File
@@ -46,6 +46,12 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
Name = Name,
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace)
}
// Note: the recordings manager is intentionally NOT registered as a plugin
// page. Plugin pages live under the admin Dashboard and are admin-gated, which
// both caused a redirect to the recordings page and made it inaccessible to
// normal users. Recordings are served as a standalone, user-accessible page at
// GET /Plugins/SRFPlay/Recording/Page (see RecordingController).
];
}
}
@@ -50,7 +50,7 @@ public class SRFEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition?.ChapterList != null && mediaComposition.ChapterList.Count > 0)
if (mediaComposition?.HasChapters == true)
{
var chapter = mediaComposition.ChapterList[0];
results.Add(new RemoteSearchResult
@@ -93,7 +93,7 @@ public class SRFEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
if (mediaComposition?.HasChapters != true)
{
_logger.LogWarning("No chapter information found for URN: {Urn}", urn);
return result;
@@ -92,7 +92,7 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
}
// Extract images from chapters
if (mediaComposition.ChapterList != null && mediaComposition.ChapterList.Count > 0)
if (mediaComposition.HasChapters)
{
var chapter = mediaComposition.ChapterList[0];
if (!string.IsNullOrEmpty(chapter.ImageUrl))
@@ -70,7 +70,7 @@ public class SRFMediaProvider : IMediaSourceProvider
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken, cacheDuration).ConfigureAwait(false);
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
if (mediaComposition?.HasChapters != true)
{
_logger.LogWarning("No chapters found for URN: {Urn}", urn);
return sources;
@@ -116,7 +116,7 @@ public class SRFMediaProvider : IMediaSourceProvider
// Force fresh fetch with short cache duration
var freshMediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken, 1).ConfigureAwait(false);
if (freshMediaComposition?.ChapterList != null && freshMediaComposition.ChapterList.Count > 0)
if (freshMediaComposition?.HasChapters == true)
{
var freshChapter = freshMediaComposition.ChapterList[0];
mediaSource = await _mediaSourceFactory.CreateMediaSourceAsync(
@@ -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;
@@ -63,20 +64,25 @@ public class ContentRefreshTask : IScheduledTask
return;
}
// Refresh latest content
if (config.EnableLatestContent)
// Refresh content for every business unit (one channel per unit).
var units = System.Enum.GetValues<Configuration.BusinessUnit>();
foreach (var unit in units)
{
_logger.LogInformation("Refreshing latest content");
progress?.Report(25);
await _contentRefreshService.RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
}
var businessUnit = unit.ToLowerString();
// Refresh trending content
if (config.EnableTrendingContent)
{
_logger.LogInformation("Refreshing trending content");
progress?.Report(75);
await _contentRefreshService.RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
if (config.EnableLatestContent)
{
_logger.LogInformation("Refreshing latest content for {BusinessUnit}", businessUnit);
progress?.Report(25);
await _contentRefreshService.RefreshLatestContentAsync(businessUnit, cancellationToken).ConfigureAwait(false);
}
if (config.EnableTrendingContent)
{
_logger.LogInformation("Refreshing trending content for {BusinessUnit}", businessUnit);
progress?.Report(75);
await _contentRefreshService.RefreshTrendingContentAsync(businessUnit, cancellationToken).ConfigureAwait(false);
}
}
progress?.Report(100);
@@ -98,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;
@@ -60,25 +61,8 @@ public class ExpirationCheckTask : IScheduledTask
return;
}
// Get expiration statistics first
// Check and remove expired content
progress?.Report(25);
var (total, expired, expiringSoon) = await _expirationService.GetExpirationStatisticsAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Expiration statistics - Total: {Total}, Expired: {Expired}, Expiring Soon: {ExpiringSoon}",
total,
expired,
expiringSoon);
if (expired == 0)
{
_logger.LogInformation("No expired content found");
progress?.Report(100);
return;
}
// Remove expired content
progress?.Report(50);
var removedCount = await _expirationService.CheckAndRemoveExpiredContentAsync(cancellationToken).ConfigureAwait(false);
// Clean up old stream proxy mappings
@@ -104,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))
};
}
}
@@ -0,0 +1,73 @@
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 checks and manages sport livestream recordings.
/// Runs every 2 minutes to start scheduled recordings when streams go live
/// and stop recordings when they end.
/// </summary>
public class RecordingSchedulerTask : IScheduledTask
{
private readonly ILogger<RecordingSchedulerTask> _logger;
private readonly IRecordingService _recordingService;
/// <summary>
/// Initializes a new instance of the <see cref="RecordingSchedulerTask"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="recordingService">The recording service.</param>
public RecordingSchedulerTask(
ILogger<RecordingSchedulerTask> logger,
IRecordingService recordingService)
{
_logger = logger;
_recordingService = recordingService;
}
/// <inheritdoc />
public string Name => "Process SRF Play Recordings";
/// <inheritdoc />
public string Description => "Checks scheduled recordings and starts/stops them as needed";
/// <inheritdoc />
public string Category => "SRF Play";
/// <inheritdoc />
public string Key => "SRFPlayRecordingScheduler";
/// <inheritdoc />
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
_logger.LogDebug("Processing SRF Play recordings");
progress?.Report(0);
try
{
await _recordingService.ProcessRecordingsAsync(cancellationToken).ConfigureAwait(false);
progress?.Report(100);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing recordings");
throw;
}
}
/// <inheritdoc />
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
return new[]
{
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))
};
}
}
+18 -2
View File
@@ -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>();
@@ -41,11 +46,22 @@ public class ServiceRegistrator : IPluginServiceRegistrator
// Register media source provider
serviceCollection.AddSingleton<SRFMediaProvider>();
// Register recording service
serviceCollection.AddSingleton<IRecordingService, RecordingService>();
// Register scheduled tasks
serviceCollection.AddSingleton<IScheduledTask, ContentRefreshTask>();
serviceCollection.AddSingleton<IScheduledTask, ExpirationCheckTask>();
serviceCollection.AddSingleton<IScheduledTask, RecordingSchedulerTask>();
serviceCollection.AddSingleton<IScheduledTask, ResumeCleanupTask>();
// Register channel - must register as IChannel interface for Jellyfin to discover it
serviceCollection.AddSingleton<IChannel, SRFPlayChannel>();
// 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
// content (its tile appears empty) - see SrgChannelBase.
serviceCollection.AddSingleton<IChannel, SrfChannel>();
serviceCollection.AddSingleton<IChannel, RtsChannel>();
serviceCollection.AddSingleton<IChannel, RsiChannel>();
serviceCollection.AddSingleton<IChannel, RtrChannel>();
serviceCollection.AddSingleton<IChannel, SwiChannel>();
}
}
@@ -18,8 +18,10 @@ public class CategoryService : ICategoryService
private readonly ILogger<CategoryService> _logger;
private readonly ISRFApiClientFactory _apiClientFactory;
private readonly TimeSpan _topicsCacheDuration = TimeSpan.FromHours(24);
private Dictionary<string, PlayV3Topic>? _topicsCache;
private DateTime _topicsCacheExpiry = DateTime.MinValue;
// Cache topics per business unit. A single shared cache would return one unit's
// topics (e.g. German SRF) even after the user switched to another (e.g. French RTS).
private readonly Dictionary<string, (Dictionary<string, PlayV3Topic> Topics, DateTime Expiry)> _topicsCacheByUnit = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Initializes a new instance of the <see cref="CategoryService"/> class.
@@ -35,11 +37,11 @@ public class CategoryService : ICategoryService
/// <inheritdoc />
public async Task<List<PlayV3Topic>> GetTopicsAsync(string businessUnit, CancellationToken cancellationToken = default)
{
// Return cached topics if still valid
if (_topicsCache != null && DateTime.UtcNow < _topicsCacheExpiry)
// Return cached topics for this business unit if still valid
if (_topicsCacheByUnit.TryGetValue(businessUnit, out var cached) && DateTime.UtcNow < cached.Expiry)
{
_logger.LogDebug("Returning cached topics for business unit: {BusinessUnit}", businessUnit);
return _topicsCache.Values.ToList();
return cached.Topics.Values.ToList();
}
_logger.LogInformation("Fetching topics for business unit: {BusinessUnit}", businessUnit);
@@ -48,13 +50,13 @@ public class CategoryService : ICategoryService
if (topics != null && topics.Count > 0)
{
// Cache topics by ID for quick lookups
_topicsCache = topics
// Cache topics by ID for quick lookups, scoped to this business unit
var byId = topics
.Where(t => !string.IsNullOrEmpty(t.Id))
.ToDictionary(t => t.Id!, t => t);
_topicsCacheExpiry = DateTime.UtcNow.Add(_topicsCacheDuration);
_topicsCacheByUnit[businessUnit] = (byId, DateTime.UtcNow.Add(_topicsCacheDuration));
_logger.LogInformation("Cached {Count} topics for business unit: {BusinessUnit}", _topicsCache.Count, businessUnit);
_logger.LogInformation("Cached {Count} topics for business unit: {BusinessUnit}", byId.Count, businessUnit);
}
return topics ?? new List<PlayV3Topic>();
@@ -63,13 +65,14 @@ public class CategoryService : ICategoryService
/// <inheritdoc />
public async Task<PlayV3Topic?> GetTopicByIdAsync(string topicId, string businessUnit, CancellationToken cancellationToken = default)
{
// Ensure topics are loaded
if (_topicsCache == null || DateTime.UtcNow >= _topicsCacheExpiry)
// Ensure topics are loaded for this business unit
if (!_topicsCacheByUnit.TryGetValue(businessUnit, out var cached) || DateTime.UtcNow >= cached.Expiry)
{
await GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
_topicsCacheByUnit.TryGetValue(businessUnit, out cached);
}
return _topicsCache?.GetValueOrDefault(topicId);
return cached.Topics?.GetValueOrDefault(topicId);
}
/// <inheritdoc />
@@ -101,8 +104,7 @@ public class CategoryService : ICategoryService
/// <inheritdoc />
public void ClearCache()
{
_topicsCache = null;
_topicsCacheExpiry = DateTime.MinValue;
_topicsCacheByUnit.Clear();
_logger.LogInformation("Topics cache cleared");
}
@@ -118,11 +118,12 @@ public class ContentExpirationService : IContentExpirationService
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
if (mediaComposition?.HasChapters != true)
{
// If we can't fetch the content, consider it expired
_logger.LogWarning("Could not fetch media composition for URN: {Urn}, treating as expired", urn);
return true;
// Don't treat API failures as expired - the content may still be available
// and a transient error (network issue, 403, API outage) shouldn't delete library items
_logger.LogWarning("Could not fetch media composition for URN: {Urn}, skipping (not treating as expired)", urn);
return false;
}
var chapter = mediaComposition.ChapterList[0];
@@ -135,72 +136,4 @@ public class ContentExpirationService : IContentExpirationService
return isExpired;
}
/// <summary>
/// Gets statistics about content expiration.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Tuple with total count, expired count, and items expiring soon.</returns>
public async Task<(int Total, int Expired, int ExpiringSoon)> GetExpirationStatisticsAsync(CancellationToken cancellationToken)
{
var total = 0;
var expired = 0;
var expiringSoon = 0;
var soonThreshold = DateTime.UtcNow.AddDays(7); // Items expiring within 7 days
try
{
var query = new InternalItemsQuery
{
HasAnyProviderId = new Dictionary<string, string> { { "SRF", string.Empty } },
IsVirtualItem = false
};
var items = _libraryManager.GetItemList(query);
total = items.Count;
foreach (var item in items)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
try
{
var urn = item.ProviderIds.GetValueOrDefault("SRF");
if (string.IsNullOrEmpty(urn))
{
continue;
}
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition?.ChapterList != null && mediaComposition.ChapterList.Count > 0)
{
var chapter = mediaComposition.ChapterList[0];
if (_streamResolver.IsContentExpired(chapter))
{
expired++;
}
else if (chapter.ValidTo.HasValue && chapter.ValidTo.Value.ToUniversalTime() <= soonThreshold)
{
expiringSoon++;
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking expiration statistics for item: {Name}", item.Name);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting expiration statistics");
}
return (total, expired, expiringSoon);
}
}
@@ -5,6 +5,7 @@ using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Utilities;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
@@ -33,9 +34,10 @@ public class ContentRefreshService : IContentRefreshService
/// <summary>
/// Refreshes latest content from SRF API using Play v3.
/// </summary>
/// <param name="businessUnit">The business unit to fetch content for (e.g. "srf", "rts").</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of URNs for new content.</returns>
public async Task<List<string>> RefreshLatestContentAsync(CancellationToken cancellationToken)
public async Task<List<string>> RefreshLatestContentAsync(string businessUnit, CancellationToken cancellationToken)
{
var config = Plugin.Instance?.Configuration;
if (config == null || !config.EnableLatestContent)
@@ -45,7 +47,7 @@ public class ContentRefreshService : IContentRefreshService
}
return await FetchVideosFromShowsAsync(
config.BusinessUnit.ToString().ToLowerInvariant(),
businessUnit,
minEpisodeCount: 0,
maxShows: 20,
videosPerShow: 1,
@@ -57,9 +59,10 @@ public class ContentRefreshService : IContentRefreshService
/// Refreshes trending content from SRF API using Play v3.
/// Gets videos from shows with the most episodes.
/// </summary>
/// <param name="businessUnit">The business unit to fetch content for (e.g. "srf", "rts").</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of URNs for trending content.</returns>
public async Task<List<string>> RefreshTrendingContentAsync(CancellationToken cancellationToken)
public async Task<List<string>> RefreshTrendingContentAsync(string businessUnit, CancellationToken cancellationToken)
{
var config = Plugin.Instance?.Configuration;
if (config == null || !config.EnableTrendingContent)
@@ -69,7 +72,7 @@ public class ContentRefreshService : IContentRefreshService
}
return await FetchVideosFromShowsAsync(
config.BusinessUnit.ToString().ToLowerInvariant(),
businessUnit,
minEpisodeCount: 10,
maxShows: 15,
videosPerShow: 2,
@@ -167,48 +170,4 @@ public class ContentRefreshService : IContentRefreshService
return urns;
}
/// <summary>
/// Refreshes all content (latest and trending).
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Tuple with counts of latest and trending items.</returns>
public async Task<(int LatestCount, int TrendingCount)> RefreshAllContentAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting full content refresh");
var latestUrns = await RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
var trendingUrns = await RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
var latestCount = latestUrns.Count;
var trendingCount = trendingUrns.Count;
_logger.LogInformation(
"Content refresh completed. Latest: {LatestCount}, Trending: {TrendingCount}",
latestCount,
trendingCount);
return (latestCount, trendingCount);
}
/// <summary>
/// Gets content recommendations (combines latest and trending).
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of recommended URNs.</returns>
public async Task<List<string>> GetRecommendedContentAsync(CancellationToken cancellationToken)
{
var recommendations = new HashSet<string>();
var latestUrns = await RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
var trendingUrns = await RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
foreach (var urn in latestUrns.Concat(trendingUrns))
{
recommendations.Add(urn);
}
_logger.LogInformation("Generated {Count} content recommendations", recommendations.Count);
return recommendations.ToList();
}
}
@@ -14,11 +14,4 @@ public interface IContentExpirationService
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The number of items removed.</returns>
Task<int> CheckAndRemoveExpiredContentAsync(CancellationToken cancellationToken);
/// <summary>
/// Gets statistics about content expiration.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Tuple with total count, expired count, and items expiring soon.</returns>
Task<(int Total, int Expired, int ExpiringSoon)> GetExpirationStatisticsAsync(CancellationToken cancellationToken);
}
@@ -12,28 +12,16 @@ public interface IContentRefreshService
/// <summary>
/// Refreshes latest content from SRF API using Play v3.
/// </summary>
/// <param name="businessUnit">The business unit to fetch content for (e.g. "srf", "rts").</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of URNs for new content.</returns>
Task<List<string>> RefreshLatestContentAsync(CancellationToken cancellationToken);
Task<List<string>> RefreshLatestContentAsync(string businessUnit, CancellationToken cancellationToken);
/// <summary>
/// Refreshes trending content from SRF API using Play v3.
/// </summary>
/// <param name="businessUnit">The business unit to fetch content for (e.g. "srf", "rts").</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of URNs for trending content.</returns>
Task<List<string>> RefreshTrendingContentAsync(CancellationToken cancellationToken);
/// <summary>
/// Refreshes all content (latest and trending).
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Tuple with counts of latest and trending items.</returns>
Task<(int LatestCount, int TrendingCount)> RefreshAllContentAsync(CancellationToken cancellationToken);
/// <summary>
/// Gets content recommendations (combines latest and trending).
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of recommended URNs.</returns>
Task<List<string>> GetRecommendedContentAsync(CancellationToken cancellationToken);
Task<List<string>> RefreshTrendingContentAsync(string businessUnit, CancellationToken cancellationToken);
}
@@ -22,20 +22,8 @@ public interface IMetadataCache
/// <param name="mediaComposition">The media composition to cache.</param>
void SetMediaComposition(string urn, MediaComposition mediaComposition);
/// <summary>
/// Removes media composition from cache.
/// </summary>
/// <param name="urn">The URN.</param>
void RemoveMediaComposition(string urn);
/// <summary>
/// Clears all cached data.
/// </summary>
void Clear();
/// <summary>
/// Gets the cache statistics.
/// </summary>
/// <returns>A tuple with cache count and size estimate.</returns>
(int Count, long SizeEstimate) GetStatistics();
}
@@ -0,0 +1,65 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
/// <summary>
/// Service for managing sport livestream recordings.
/// </summary>
public interface IRecordingService
{
/// <summary>
/// Gets upcoming sport livestreams that can be recorded.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of upcoming sport livestreams.</returns>
Task<IReadOnlyList<PlayV3TvProgram>> GetUpcomingScheduleAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Schedules a livestream for recording.
/// </summary>
/// <param name="urn">The SRF URN to record.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The created recording entry.</returns>
Task<RecordingEntry> ScheduleRecordingAsync(string urn, CancellationToken cancellationToken = default);
/// <summary>
/// Cancels a scheduled recording.
/// </summary>
/// <param name="recordingId">The recording ID.</param>
/// <returns>True if cancelled.</returns>
bool CancelRecording(string recordingId);
/// <summary>
/// Stops an active recording.
/// </summary>
/// <param name="recordingId">The recording ID.</param>
/// <returns>True if stopped.</returns>
bool StopRecording(string recordingId);
/// <summary>
/// Gets all recordings by state.
/// </summary>
/// <param name="stateFilter">Optional state filter.</param>
/// <returns>List of matching recording entries.</returns>
IReadOnlyList<RecordingEntry> GetRecordings(RecordingState? stateFilter = null);
/// <summary>
/// Deletes a completed recording (entry and optionally the file).
/// </summary>
/// <param name="recordingId">The recording ID.</param>
/// <param name="deleteFile">Whether to delete the file too.</param>
/// <returns>True if deleted.</returns>
bool DeleteRecording(string recordingId, bool deleteFile = true);
/// <summary>
/// Checks scheduled recordings and starts/stops them as needed.
/// Called periodically by the scheduler task.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task representing the async operation.</returns>
Task ProcessRecordingsAsync(CancellationToken cancellationToken = default);
}
@@ -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);
}
@@ -1,3 +1,4 @@
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
@@ -8,15 +9,6 @@ namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
/// </summary>
public interface IStreamProxyService
{
/// <summary>
/// Registers a stream for proxying with an already-authenticated URL.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="authenticatedUrl">The authenticated stream URL.</param>
/// <param name="urn">The SRF URN for this content (used for re-fetching fresh URLs).</param>
/// <param name="isLiveStream">Whether this is a livestream (livestreams always fetch fresh URLs).</param>
void RegisterStream(string itemId, string authenticatedUrl, string? urn = null, bool isLiveStream = false);
/// <summary>
/// Registers a stream for deferred authentication (authenticates on first playback request).
/// Use this when browsing to avoid wasting 30-second tokens before the user clicks play.
@@ -61,6 +53,28 @@ public interface IStreamProxyService
/// <returns>The segment content as bytes.</returns>
Task<byte[]?> GetSegmentAsync(string itemId, string segmentPath, string? queryString = null, CancellationToken cancellationToken = default);
/// <summary>
/// Fetches a segment from the original source as a streaming response.
/// Returns the HttpResponseMessage for streaming directly to the client, reducing TTFB.
/// The caller is responsible for disposing the response.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="segmentPath">The segment path.</param>
/// <param name="queryString">The original query string from the request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The HTTP response for streaming, or null if not found.</returns>
Task<HttpResponseMessage?> GetSegmentStreamAsync(string itemId, string segmentPath, string? queryString = null, CancellationToken cancellationToken = default);
/// <summary>
/// Rewrites URLs in a variant (sub) manifest to point to the proxy.
/// </summary>
/// <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, bool isLiveStream = false);
/// <summary>
/// Cleans up old and expired stream mappings.
/// </summary>
@@ -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;
@@ -62,9 +63,15 @@ public class MediaSourceFactory : IMediaSourceFactory
return Task.FromResult<MediaSourceInfo?>(null);
}
// Detect if this is a live stream
var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" ||
urn.Contains("livestream", StringComparison.OrdinalIgnoreCase);
// A scheduled livestream is only treated as live when the broadcast window is open.
// 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" ||
UrnHelper.IsLivestreamUrn(urn);
var now = DateTime.UtcNow;
var isLiveStream = isScheduledLivestream &&
(chapter.ValidFrom == null || chapter.ValidFrom.Value.ToUniversalTime() <= now) &&
(chapter.ValidTo == null || chapter.ValidTo.Value.ToUniversalTime() > now);
// Register stream with UNAUTHENTICATED URL - proxy will authenticate on-demand
// This avoids wasting 30-second tokens during category browsing
@@ -91,15 +98,15 @@ public class MediaSourceFactory : IMediaSourceFactory
Protocol = MediaProtocol.Http,
// Use "hls" to trigger hls.js player in web client
Container = "hls",
SupportsDirectStream = true,
SupportsDirectStream = false,
SupportsDirectPlay = true,
SupportsTranscoding = false,
SupportsTranscoding = true,
IsRemote = true,
Type = MediaSourceType.Default,
RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null,
VideoType = VideoType.VideoFile,
IsInfiniteStream = isLiveStream,
// Don't use RequiresOpening - it forces Jellyfin to transcode which breaks token auth
// RequiresOpening = false: proxy handles auth directly, no need for Jellyfin to open the stream
RequiresOpening = false,
RequiresClosing = false,
// Disable probing - we provide stream info directly
+12 -132
View File
@@ -1,6 +1,5 @@
using System;
using System.Collections.Concurrent;
using System.Threading;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Microsoft.Extensions.Logging;
@@ -10,12 +9,10 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for caching metadata from SRF API.
/// </summary>
public sealed class MetadataCache : IMetadataCache, IDisposable
public sealed class MetadataCache : IMetadataCache
{
private readonly ILogger<MetadataCache> _logger;
private readonly ConcurrentDictionary<string, CacheEntry<MediaComposition>> _mediaCompositionCache;
private readonly ReaderWriterLockSlim _lock;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="MetadataCache"/> class.
@@ -25,19 +22,6 @@ public sealed class MetadataCache : IMetadataCache, IDisposable
{
_logger = logger;
_mediaCompositionCache = new ConcurrentDictionary<string, CacheEntry<MediaComposition>>();
_lock = new ReaderWriterLockSlim();
}
/// <summary>
/// Disposes resources.
/// </summary>
public void Dispose()
{
if (!_disposed)
{
_lock?.Dispose();
_disposed = true;
}
}
/// <summary>
@@ -53,30 +37,15 @@ public sealed class MetadataCache : IMetadataCache, IDisposable
return null;
}
try
if (_mediaCompositionCache.TryGetValue(urn, out var entry))
{
_lock.EnterReadLock();
try
if (entry.IsValid(cacheDurationMinutes))
{
if (_mediaCompositionCache.TryGetValue(urn, out var entry))
{
if (entry.IsValid(cacheDurationMinutes))
{
_logger.LogDebug("Cache hit for URN: {Urn}", urn);
return entry.Value;
}
_logger.LogDebug("Cache hit for URN: {Urn}", urn);
return entry.Value;
}
_logger.LogDebug("Cache entry expired for URN: {Urn}", urn);
}
}
finally
{
_lock.ExitReadLock();
}
}
catch (ObjectDisposedException)
{
return null;
_logger.LogDebug("Cache entry expired for URN: {Urn}", urn);
}
return null;
@@ -94,56 +63,9 @@ public sealed class MetadataCache : IMetadataCache, IDisposable
return;
}
try
{
_lock.EnterWriteLock();
try
{
var entry = new CacheEntry<MediaComposition>(mediaComposition);
_mediaCompositionCache.AddOrUpdate(urn, entry, (key, oldValue) => entry);
_logger.LogDebug("Cached media composition for URN: {Urn}", urn);
}
finally
{
_lock.ExitWriteLock();
}
}
catch (ObjectDisposedException)
{
// Cache is disposed, ignore
}
}
/// <summary>
/// Removes media composition from cache.
/// </summary>
/// <param name="urn">The URN.</param>
public void RemoveMediaComposition(string urn)
{
if (string.IsNullOrEmpty(urn))
{
return;
}
try
{
_lock.EnterWriteLock();
try
{
if (_mediaCompositionCache.TryRemove(urn, out _))
{
_logger.LogDebug("Removed cached media composition for URN: {Urn}", urn);
}
}
finally
{
_lock.ExitWriteLock();
}
}
catch (ObjectDisposedException)
{
// Cache is disposed, ignore
}
var entry = new CacheEntry<MediaComposition>(mediaComposition);
_mediaCompositionCache.AddOrUpdate(urn, entry, (key, oldValue) => entry);
_logger.LogDebug("Cached media composition for URN: {Urn}", urn);
}
/// <summary>
@@ -151,50 +73,8 @@ public sealed class MetadataCache : IMetadataCache, IDisposable
/// </summary>
public void Clear()
{
try
{
_lock.EnterWriteLock();
try
{
_mediaCompositionCache.Clear();
_logger.LogInformation("Cleared metadata cache");
}
finally
{
_lock.ExitWriteLock();
}
}
catch (ObjectDisposedException)
{
// Cache is disposed, ignore
}
}
/// <summary>
/// Gets the cache statistics.
/// </summary>
/// <returns>A tuple with cache count and size estimate.</returns>
public (int Count, long SizeEstimate) GetStatistics()
{
try
{
_lock.EnterReadLock();
try
{
var count = _mediaCompositionCache.Count;
// Rough estimate: average 50KB per entry
var sizeEstimate = count * 50L * 1024;
return (count, sizeEstimate);
}
finally
{
_lock.ExitReadLock();
}
}
catch (ObjectDisposedException)
{
return (0, 0);
}
_mediaCompositionCache.Clear();
_logger.LogInformation("Cleared metadata cache");
}
/// <summary>
@@ -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);
}
}
}
@@ -0,0 +1,581 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
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;
namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for managing sport livestream recordings using ffmpeg.
/// </summary>
public class RecordingService : IRecordingService, IDisposable
{
private readonly ILogger<RecordingService> _logger;
private readonly ISRFApiClientFactory _apiClientFactory;
private readonly IStreamProxyService _proxyService;
private readonly IStreamUrlResolver _streamUrlResolver;
private readonly IMediaCompositionFetcher _mediaCompositionFetcher;
private readonly IServerApplicationHost _appHost;
private readonly IMediaEncoder _mediaEncoder;
private readonly ConcurrentDictionary<string, Process> _activeProcesses = new();
private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true };
private readonly SemaphoreSlim _persistLock = new(1, 1);
private readonly SemaphoreSlim _processLock = new(1, 1);
private List<RecordingEntry> _recordings = new();
private bool _loaded;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="RecordingService"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="apiClientFactory">The API client factory.</param>
/// <param name="proxyService">The stream proxy service.</param>
/// <param name="streamUrlResolver">The stream URL resolver.</param>
/// <param name="mediaCompositionFetcher">The media composition fetcher.</param>
/// <param name="appHost">The application host.</param>
/// <param name="mediaEncoder">The media encoder for ffmpeg path.</param>
public RecordingService(
ILogger<RecordingService> logger,
ISRFApiClientFactory apiClientFactory,
IStreamProxyService proxyService,
IStreamUrlResolver streamUrlResolver,
IMediaCompositionFetcher mediaCompositionFetcher,
IServerApplicationHost appHost,
IMediaEncoder mediaEncoder)
{
_logger = logger;
_apiClientFactory = apiClientFactory;
_proxyService = proxyService;
_streamUrlResolver = streamUrlResolver;
_mediaCompositionFetcher = mediaCompositionFetcher;
_appHost = appHost;
_mediaEncoder = mediaEncoder;
}
private string GetDataFilePath()
{
var dataPath = Plugin.Instance?.DataFolderPath ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "jellyfin", "plugins", "SRFPlay");
Directory.CreateDirectory(dataPath);
return Path.Combine(dataPath, "recordings.json");
}
private string GetRecordingOutputPath()
{
var config = Plugin.Instance?.Configuration;
var path = config?.RecordingOutputPath;
if (string.IsNullOrWhiteSpace(path))
{
path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "SRFRecordings");
}
Directory.CreateDirectory(path);
return path;
}
private string GetServerBaseUrl()
{
var config = Plugin.Instance?.Configuration;
if (config != null && !string.IsNullOrWhiteSpace(config.PublicServerUrl))
{
return config.PublicServerUrl.TrimEnd('/');
}
// For local ffmpeg access, use localhost directly
return "http://localhost:8096";
}
private async Task LoadRecordingsAsync()
{
if (_loaded)
{
return;
}
var filePath = GetDataFilePath();
if (File.Exists(filePath))
{
try
{
var json = await File.ReadAllTextAsync(filePath).ConfigureAwait(false);
_recordings = JsonSerializer.Deserialize<List<RecordingEntry>>(json) ?? new List<RecordingEntry>();
_logger.LogInformation("Loaded {Count} recording entries from {Path}", _recordings.Count, filePath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load recordings from {Path}", filePath);
_recordings = new List<RecordingEntry>();
}
}
_loaded = true;
}
private async Task SaveRecordingsAsync()
{
await _persistLock.WaitAsync().ConfigureAwait(false);
try
{
var filePath = GetDataFilePath();
var json = JsonSerializer.Serialize(_recordings, _jsonOptions);
await File.WriteAllTextAsync(filePath, json).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save recordings");
}
finally
{
_persistLock.Release();
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<PlayV3TvProgram>> GetUpcomingScheduleAsync(CancellationToken cancellationToken)
{
var units = System.Enum.GetValues<Configuration.BusinessUnit>();
using var apiClient = _apiClientFactory.CreateClient();
// Aggregate sport livestreams across every business unit so the recordings
// page shows events from all languages.
var all = new List<PlayV3TvProgram>();
foreach (var unit in units)
{
var businessUnit = unit.ToString().ToLowerInvariant();
var livestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
if (livestreams != null)
{
all.AddRange(livestreams);
}
}
// Filter to only future/current livestreams that aren't blocked
return all
.Where(ls => ls.Blocked != true && (ls.ValidTo == null || ls.ValidTo.Value.ToUniversalTime() > DateTime.UtcNow))
.OrderBy(ls => ls.ValidFrom)
.ToList();
}
/// <inheritdoc />
public async Task<RecordingEntry> ScheduleRecordingAsync(string urn, CancellationToken cancellationToken)
{
await LoadRecordingsAsync().ConfigureAwait(false);
// Check if already scheduled
var existing = _recordings.FirstOrDefault(r => r.Urn == urn && r.State is RecordingState.Scheduled or RecordingState.WaitingForStream or RecordingState.Recording);
if (existing != null)
{
_logger.LogInformation("Recording already exists for URN {Urn} in state {State}", urn, existing.State);
return existing;
}
// Fetch metadata for the URN. The unit is encoded in the URN itself, so use that to
// query the right schedule regardless of which units are enabled.
var config = Plugin.Instance?.Configuration;
var businessUnit = ParseBusinessUnitFromUrn(urn)
?? (config?.BusinessUnit ?? Configuration.BusinessUnit.SRF).ToString().ToLowerInvariant();
using var apiClient = _apiClientFactory.CreateClient();
var livestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
var program = livestreams?.FirstOrDefault(ls => ls.Urn == urn);
var entry = new RecordingEntry
{
Id = Guid.NewGuid().ToString("N"),
Urn = urn,
BusinessUnit = ParseBusinessUnitFromUrn(urn) ?? businessUnit,
Title = program?.Title ?? urn,
Description = program?.Lead ?? program?.Description,
ImageUrl = program?.ImageUrl,
ValidFrom = program?.ValidFrom,
ValidTo = program?.ValidTo,
State = RecordingState.Scheduled,
CreatedAt = DateTime.UtcNow
};
_recordings.Add(entry);
await SaveRecordingsAsync().ConfigureAwait(false);
_logger.LogInformation("Scheduled recording for '{Title}' (URN: {Urn}, starts: {ValidFrom})", entry.Title, urn, entry.ValidFrom);
return entry;
}
/// <summary>
/// Extracts the business unit from an SRF URN of the form "urn:&lt;bu&gt;:&lt;type&gt;:...".
/// </summary>
/// <param name="urn">The URN.</param>
/// <returns>The lowercase business unit, or null if it cannot be determined.</returns>
private static string? ParseBusinessUnitFromUrn(string urn)
{
if (string.IsNullOrEmpty(urn))
{
return null;
}
var parts = urn.Split(':');
return parts.Length >= 2 && !string.IsNullOrEmpty(parts[1])
? parts[1].ToLowerInvariant()
: null;
}
/// <inheritdoc />
public bool CancelRecording(string recordingId)
{
var entry = _recordings.FirstOrDefault(r => r.Id == recordingId);
if (entry == null)
{
return false;
}
if (entry.State == RecordingState.Recording)
{
StopFfmpeg(recordingId);
}
entry.State = RecordingState.Cancelled;
entry.RecordingEndedAt = DateTime.UtcNow;
_ = SaveRecordingsAsync();
_logger.LogInformation("Cancelled recording '{Title}' ({Id})", entry.Title, recordingId);
return true;
}
/// <inheritdoc />
public bool StopRecording(string recordingId)
{
var entry = _recordings.FirstOrDefault(r => r.Id == recordingId && r.State == RecordingState.Recording);
if (entry == null)
{
return false;
}
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;
}
_ = SaveRecordingsAsync();
_logger.LogInformation("Stopped recording '{Title}' ({Id})", entry.Title, recordingId);
return true;
}
/// <inheritdoc />
public IReadOnlyList<RecordingEntry> GetRecordings(RecordingState? stateFilter)
{
// Ensure loaded synchronously for simple reads
if (!_loaded)
{
LoadRecordingsAsync().GetAwaiter().GetResult();
}
if (stateFilter.HasValue)
{
return _recordings.Where(r => r.State == stateFilter.Value).OrderByDescending(r => r.CreatedAt).ToList();
}
return _recordings.OrderByDescending(r => r.CreatedAt).ToList();
}
/// <inheritdoc />
public bool DeleteRecording(string recordingId, bool deleteFile)
{
var entry = _recordings.FirstOrDefault(r => r.Id == recordingId);
if (entry == null)
{
return false;
}
if (entry.State == RecordingState.Recording)
{
StopFfmpeg(recordingId);
}
if (deleteFile && !string.IsNullOrEmpty(entry.OutputPath) && File.Exists(entry.OutputPath))
{
try
{
File.Delete(entry.OutputPath);
_logger.LogInformation("Deleted recording file: {Path}", entry.OutputPath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete recording file: {Path}", entry.OutputPath);
}
}
_recordings.Remove(entry);
_ = SaveRecordingsAsync();
_logger.LogInformation("Deleted recording entry '{Title}' ({Id})", entry.Title, recordingId);
return true;
}
/// <inheritdoc />
public async Task ProcessRecordingsAsync(CancellationToken cancellationToken)
{
// Prevent overlapping scheduler runs from spawning duplicate ffmpeg processes
if (!await _processLock.WaitAsync(0, CancellationToken.None).ConfigureAwait(false))
{
_logger.LogDebug("ProcessRecordingsAsync already running, skipping");
return;
}
try
{
await ProcessRecordingsCoreAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_processLock.Release();
}
}
private async Task ProcessRecordingsCoreAsync(CancellationToken cancellationToken)
{
await LoadRecordingsAsync().ConfigureAwait(false);
var now = DateTime.UtcNow;
var changed = false;
foreach (var entry in _recordings.ToList())
{
// Normalize ValidFrom/ValidTo to UTC for correct comparison
var validFromUtc = entry.ValidFrom.HasValue ? entry.ValidFrom.Value.ToUniversalTime() : (DateTime?)null;
var validToUtc = entry.ValidTo.HasValue ? entry.ValidTo.Value.ToUniversalTime() : (DateTime?)null;
switch (entry.State)
{
case RecordingState.Scheduled:
case RecordingState.WaitingForStream:
// Check if it's time to start recording
if (validFromUtc.HasValue && validFromUtc.Value <= now.AddMinutes(2))
{
_logger.LogInformation(
"Time to start recording '{Title}': ValidFrom={ValidFrom} (UTC: {ValidFromUtc}), Now={Now}",
entry.Title,
entry.ValidFrom,
validFromUtc,
now);
changed |= await TryStartRecordingAsync(entry, cancellationToken).ConfigureAwait(false);
}
break;
case RecordingState.Recording:
// Check if recording should stop (ValidTo reached or process died)
if (validToUtc.HasValue && validToUtc.Value <= now)
{
_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;
}
changed = true;
}
else if (!_activeProcesses.ContainsKey(entry.Id))
{
// ffmpeg process died unexpectedly — try to restart
_logger.LogWarning("ffmpeg process for '{Title}' is no longer running, attempting restart", entry.Title);
changed |= await TryStartRecordingAsync(entry, cancellationToken).ConfigureAwait(false);
}
break;
}
}
if (changed)
{
await SaveRecordingsAsync().ConfigureAwait(false);
}
}
private async Task<bool> TryStartRecordingAsync(RecordingEntry entry, CancellationToken cancellationToken)
{
try
{
// Fetch the media composition to get the stream URL
var mediaComposition = await _mediaCompositionFetcher.GetMediaCompositionAsync(entry.Urn, cacheDurationOverride: 2, cancellationToken: cancellationToken).ConfigureAwait(false);
var chapter = mediaComposition?.ChapterList is { Count: > 0 } list ? list[0] : null;
if (chapter == null)
{
_logger.LogDebug("No chapter found for '{Title}', stream may not be live yet", entry.Title);
entry.State = RecordingState.WaitingForStream;
return true;
}
var config = Plugin.Instance?.Configuration;
var quality = config?.QualityPreference ?? Configuration.QualityPreference.Auto;
var streamUrl = _streamUrlResolver.GetStreamUrl(chapter, quality);
if (string.IsNullOrEmpty(streamUrl))
{
_logger.LogDebug("No stream URL available for '{Title}', waiting", entry.Title);
entry.State = RecordingState.WaitingForStream;
return true;
}
// 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);
_proxyService.RegisterStreamDeferred(itemId, streamUrl, entry.Urn, isLiveStream);
// 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 = DisplayTime.Now().ToString("yyyy-MM-dd_HHmm", CultureInfo.InvariantCulture);
var outputPath = Path.Combine(GetRecordingOutputPath(), $"{safeTitle}_{timestamp}.mkv");
entry.OutputPath = outputPath;
// Start ffmpeg
StartFfmpeg(entry.Id, proxyUrl, outputPath);
entry.State = RecordingState.Recording;
entry.RecordingStartedAt = DateTime.UtcNow;
_logger.LogInformation("Started recording '{Title}' to {OutputPath}", entry.Title, outputPath);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to start recording '{Title}'", entry.Title);
entry.State = RecordingState.Failed;
entry.ErrorMessage = ex.Message;
return true;
}
}
private void StartFfmpeg(string recordingId, string inputUrl, string outputPath)
{
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = _mediaEncoder.EncoderPath,
Arguments = $"-y -i \"{inputUrl}\" -c copy -movflags +faststart \"{outputPath}\"",
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardError = true,
CreateNoWindow = true
},
EnableRaisingEvents = true
};
process.ErrorDataReceived += (_, args) =>
{
if (!string.IsNullOrEmpty(args.Data))
{
_logger.LogDebug("ffmpeg [{RecordingId}]: {Data}", recordingId, args.Data);
}
};
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);
}
private void StopFfmpeg(string recordingId)
{
if (_activeProcesses.TryRemove(recordingId, out var process))
{
try
{
if (!process.HasExited)
{
// Send 'q' to ffmpeg stdin for graceful shutdown
process.StandardInput.Write("q");
process.StandardInput.Flush();
if (!process.WaitForExit(10000))
{
_logger.LogWarning("ffmpeg did not exit gracefully for {RecordingId}, killing", recordingId);
process.Kill(true);
}
}
process.Dispose();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error stopping ffmpeg for recording {RecordingId}", recordingId);
}
}
}
private static string SanitizeFileName(string name)
{
var invalid = Path.GetInvalidFileNameChars();
var sanitized = string.Join("_", name.Split(invalid, StringSplitOptions.RemoveEmptyEntries));
// Also replace spaces and other problematic chars
sanitized = Regex.Replace(sanitized, @"[\s]+", "_");
return sanitized.Length > 100 ? sanitized[..100] : sanitized;
}
/// <inheritdoc />
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Releases resources.
/// </summary>
/// <param name="disposing">True to release managed resources.</param>
protected virtual void Dispose(bool disposing)
{
if (_disposed)
{
return;
}
if (disposing)
{
foreach (var kvp in _activeProcesses)
{
StopFfmpeg(kvp.Key);
}
_persistLock.Dispose();
_processLock.Dispose();
}
_disposed = true;
}
}
@@ -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;
}
}
}
@@ -46,45 +46,6 @@ public class StreamProxyService : IStreamProxyService
_streamMappings = new ConcurrentDictionary<string, StreamInfo>();
}
/// <summary>
/// Registers a stream for proxying.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="authenticatedUrl">The authenticated stream URL.</param>
/// <param name="urn">The SRF URN for this content (used for re-fetching fresh URLs).</param>
/// <param name="isLiveStream">Whether this is a livestream (livestreams always fetch fresh URLs).</param>
public void RegisterStream(string itemId, string authenticatedUrl, string? urn = null, bool isLiveStream = false)
{
var tokenExpiry = ExtractTokenExpiry(authenticatedUrl);
var unauthenticatedUrl = StripAuthenticationFromUrl(authenticatedUrl);
var streamInfo = new StreamInfo
{
AuthenticatedUrl = authenticatedUrl,
UnauthenticatedUrl = unauthenticatedUrl,
RegisteredAt = DateTime.UtcNow,
TokenExpiresAt = tokenExpiry,
Urn = urn,
IsLiveStream = isLiveStream,
LastLivestreamFetchAt = isLiveStream ? DateTime.UtcNow : null
};
RegisterWithGuidFormats(itemId, streamInfo);
if (tokenExpiry.HasValue)
{
_logger.LogDebug(
"Registered stream for item {ItemId} (token expires at {ExpiresAt} UTC): {Url}",
itemId,
tokenExpiry.Value,
authenticatedUrl);
}
else
{
_logger.LogDebug("Registered stream for item {ItemId}: {Url}", itemId, authenticatedUrl);
}
}
/// <summary>
/// Registers a stream for deferred authentication (authenticates on first playback request).
/// </summary>
@@ -412,6 +373,15 @@ public class StreamProxyService : IStreamProxyService
return refreshedUrl;
}
if (streamInfo.IsLiveStream)
{
// For livestreams, keep the mapping and flag for re-authentication
// rather than removing it — the next request will trigger a fresh auth
_logger.LogWarning("Failed to refresh token for livestream {ItemId}, will re-authenticate on next request", itemId);
streamInfo.NeedsAuthentication = true;
return streamInfo.AuthenticatedUrl;
}
_logger.LogWarning("Failed to refresh token for item {ItemId}, removing mapping", itemId);
_streamMappings.TryRemove(itemId, out _);
return null;
@@ -442,7 +412,7 @@ public class StreamProxyService : IStreamProxyService
// Use short cache duration (5 min) for livestreams
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(streamInfo.Urn, cancellationToken, 5).ConfigureAwait(false);
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
if (mediaComposition?.HasChapters != true)
{
_logger.LogWarning("No chapters found when refreshing livestream URL for URN: {Urn}", streamInfo.Urn);
return null;
@@ -759,6 +729,67 @@ public class StreamProxyService : IStreamProxyService
}
}
/// <summary>
/// Fetches a segment as a streaming response for direct forwarding to the client.
/// Uses HttpCompletionOption.ResponseHeadersRead to start streaming before the full
/// segment is downloaded, reducing time-to-first-byte for live streams.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="segmentPath">The segment path.</param>
/// <param name="queryString">The original query string from the request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The HTTP response for streaming, or null if not found.</returns>
public async Task<HttpResponseMessage?> GetSegmentStreamAsync(
string itemId,
string segmentPath,
string? queryString = null,
CancellationToken cancellationToken = default)
{
var authenticatedUrl = await GetAuthenticatedUrlAsync(itemId, cancellationToken).ConfigureAwait(false);
if (authenticatedUrl == null)
{
return null;
}
try
{
var baseUri = new Uri(authenticatedUrl);
var baseUrl = $"{baseUri.Scheme}://{baseUri.Host}{string.Join('/', baseUri.AbsolutePath.Split('/')[..^1])}";
var queryParams = string.Empty;
if (!string.IsNullOrEmpty(queryString))
{
queryParams = queryString.StartsWith('?') ? queryString : $"?{queryString}";
}
else if (!segmentPath.Contains("hdntl=", StringComparison.OrdinalIgnoreCase))
{
queryParams = baseUri.Query;
}
var segmentUrl = $"{baseUrl}/{segmentPath}{queryParams}";
_logger.LogDebug("Streaming segment - SegmentPath: {SegmentPath}, FullUrl: {FullUrl}", segmentPath, segmentUrl);
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
var request = new HttpRequestMessage(HttpMethod.Get, segmentUrl);
var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Segment stream request failed with {StatusCode} for {SegmentPath}", response.StatusCode, segmentPath);
response.Dispose();
return null;
}
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to stream segment {SegmentPath} for item {ItemId}", segmentPath, itemId);
return null;
}
}
/// <summary>
/// Rewrites URLs in HLS manifest to point to proxy.
/// </summary>
@@ -863,6 +894,147 @@ public class StreamProxyService : IStreamProxyService
}
}
/// <summary>
/// Rewrites URLs in a variant (sub) manifest to point to the proxy.
/// </summary>
/// <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, bool isLiveStream = false)
{
if (isLiveStream)
{
manifestContent = InjectLiveStartOffset(manifestContent);
}
string RewriteUrl(string url)
{
if (url.Contains("://", StringComparison.Ordinal))
{
// Absolute URL - extract filename and rewrite
var uri = new Uri(url.Trim());
var segments = uri.AbsolutePath.Split('/');
var fileName = segments[^1];
return $"{baseProxyUrl}/{fileName}{queryParams}";
}
// Relative URL - rewrite to proxy
return $"{baseProxyUrl}/{url.Trim()}{queryParams}";
}
var lines = manifestContent.Split('\n');
var result = new System.Text.StringBuilder();
foreach (var line in lines)
{
if (string.IsNullOrWhiteSpace(line))
{
result.AppendLine(line);
}
else if (line.StartsWith('#'))
{
// HLS tag line - check for URI="..." attributes (e.g., #EXT-X-MAP:URI="init.mp4")
if (line.Contains("URI=\"", StringComparison.Ordinal))
{
var rewrittenLine = Regex.Replace(
line,
@"URI=""([^""]+)""",
match => $"URI=\"{RewriteUrl(match.Groups[1].Value)}\"");
result.AppendLine(rewrittenLine);
}
else
{
result.AppendLine(line);
}
}
else
{
// Non-tag line with URL - rewrite it
result.AppendLine(RewriteUrl(line));
}
}
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>
@@ -883,8 +1055,9 @@ public class StreamProxyService : IStreamProxyService
_logger.LogDebug("Marking item {ItemId} for cleanup (old registration)", kvp.Key);
}
// Remove if token has expired
if (kvp.Value.TokenExpiresAt.HasValue && kvp.Value.TokenExpiresAt.Value <= now)
// Remove if token has expired — but skip livestreams since their CDN tokens
// expire every ~30s and get refreshed on-demand during playback
if (kvp.Value.TokenExpiresAt.HasValue && kvp.Value.TokenExpiresAt.Value <= now && !kvp.Value.IsLiveStream)
{
shouldRemove = true;
_logger.LogDebug("Marking item {ItemId} for cleanup (expired token)", kvp.Key);
@@ -53,7 +53,7 @@ public class StreamUrlResolver : IStreamUrlResolver
// Filter out DRM-protected content
var nonDrmResources = chapter.ResourceList
.Where(r => r.DrmList == null || r.DrmList.ToString() == "[]")
.Where(r => r.IsPlayable)
.ToList();
_logger.LogInformation(
@@ -136,8 +136,8 @@ public class StreamUrlResolver : IStreamUrlResolver
{
QualityPreference.HD => SelectHDResource(hlsResources) ?? SelectBestAvailableResource(hlsResources),
QualityPreference.SD => SelectSDResource(hlsResources) ?? SelectBestAvailableResource(hlsResources),
QualityPreference.Auto => SelectBestAvailableResource(hlsResources),
_ => SelectBestAvailableResource(hlsResources)
QualityPreference.Auto => hlsResources.FirstOrDefault(),
_ => hlsResources.FirstOrDefault()
};
if (selectedResource != null)
@@ -164,10 +164,33 @@ public class StreamUrlResolver : IStreamUrlResolver
{
if (chapter?.ResourceList == null || chapter.ResourceList.Count == 0)
{
_logger.LogWarning(
"Chapter {ChapterId}: ResourceList is null or empty. ResourceList count: {Count}, ResourceList type: {Type}",
chapter?.Id ?? "null",
chapter?.ResourceList?.Count ?? -1,
chapter?.ResourceList?.GetType().Name ?? "null");
return false;
}
return chapter.ResourceList.Any(r => r.DrmList == null || r.DrmList.ToString() == "[]");
_logger.LogInformation(
"Chapter {ChapterId}: Found {ResourceCount} resources",
chapter.Id,
chapter.ResourceList.Count);
foreach (var resource in chapter.ResourceList)
{
var urlPreview = resource.Url == null ? "null" : resource.Url.AsSpan(0, Math.Min(60, resource.Url.Length)).ToString() + "...";
_logger.LogDebug(
"Resource - URL: {Url}, Quality: {Quality}, DrmList: {DrmList}, DrmList type: {DrmListType}",
urlPreview,
resource.Quality,
resource.DrmList?.ToString() ?? "null",
resource.DrmList?.GetType().Name ?? "null");
}
var hasPlayable = chapter.ResourceList.Any(r => r.IsPlayable);
_logger.LogInformation("Chapter {ChapterId}: Has playable content: {HasPlayable}", chapter.Id, hasPlayable);
return hasPlayable;
}
/// <summary>
@@ -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;
}
}
@@ -1,5 +1,4 @@
using Jellyfin.Plugin.SRFPlay.Configuration;
using MediaBrowser.Controller.Entities;
namespace Jellyfin.Plugin.SRFPlay.Utilities;
@@ -8,18 +7,6 @@ namespace Jellyfin.Plugin.SRFPlay.Utilities;
/// </summary>
public static class Extensions
{
/// <summary>
/// Gets the SRF URN from the item's provider IDs.
/// </summary>
/// <param name="item">The base item.</param>
/// <returns>The SRF URN, or null if not found or empty.</returns>
public static string? GetSrfUrn(this BaseItem item)
{
return item.ProviderIds.TryGetValue("SRF", out var urn) && !string.IsNullOrEmpty(urn)
? urn
: null;
}
/// <summary>
/// Converts the BusinessUnit enum to its lowercase string representation.
/// </summary>
@@ -29,13 +16,4 @@ public static class Extensions
{
return businessUnit.ToString().ToLowerInvariant();
}
/// <summary>
/// Gets the plugin configuration safely, returning null if not available.
/// </summary>
/// <returns>The plugin configuration, or null if not available.</returns>
public static PluginConfiguration? GetPluginConfig()
{
return Plugin.Instance?.Configuration;
}
}
@@ -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);
}
}
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
</configuration>
+93 -35
View File
@@ -22,8 +22,9 @@ 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, SD, HD)
- Quality selection (Auto lets CDN decide, SD prefers 480p/360p, HD prefers 1080p/720p)
- HLS streaming support with Akamai token authentication
- Proxy support for routing traffic through alternate gateways
- Smart caching with reduced TTL for upcoming livestreams
@@ -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
@@ -126,7 +138,7 @@ The compiled plugin will be in `bin/Debug/net8.0/`
## Configuration
- **Business Unit**: Select the Swiss broadcasting unit (default: SRF)
- **Quality Preference**: Choose video quality (Auto/SD/HD)
- **Quality Preference**: Choose video quality — Auto (first available, CDN decides), SD, or HD
- **Content Refresh Interval**: How often to check for new content (1-168 hours)
- **Expiration Check Interval**: How often to check for expired content (1-168 hours)
- **Cache Duration**: How long to cache metadata (5-1440 minutes)
@@ -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).
@@ -154,49 +176,78 @@ If you encounter issues with the plugin:
```
Jellyfin.Plugin.SRFPlay/
├── Api/
│ ├── Models/ # API response models
│ │ ├── MediaComposition.cs
│ │ ├── Chapter.cs
│ │ ├── Resource.cs
│ ├── Models/ # API response models
│ │ ├── MediaComposition.cs # Root composition (HasChapters helper)
│ │ ├── Chapter.cs # Video/episode chapter with resources
│ │ ├── Resource.cs # Stream URL entry (IsPlayable helper)
│ │ ├── Show.cs
│ │ ├── Episode.cs
│ │ └── PlayV3/ # Play v3 API models
│ │ └── PlayV3/ # Play v3 API models
│ │ ├── PlayV3Show.cs
│ │ ├── PlayV3Topic.cs
│ │ ├── PlayV3Video.cs
│ │ ├── PlayV3TvProgram.cs
│ │ └── PlayV3TvProgramGuideResponse.cs
│ └── SRFApiClient.cs # HTTP client for SRF API
│ │ ├── PlayV3Response.cs
│ │ ├── PlayV3DirectResponse.cs
│ │ └── PlayV3DataContainer.cs
│ ├── SRFApiClient.cs # HTTP client for SRF APIs
│ ├── ISRFApiClientFactory.cs # Factory interface
│ └── SRFApiClientFactory.cs # Factory implementation
├── Channels/
│ └── SRFPlayChannel.cs # Channel implementation
│ └── SRFPlayChannel.cs # Channel implementation
├── Configuration/
│ ├── PluginConfiguration.cs
│ └── configPage.html
├── Constants/
│ └── ApiEndpoints.cs # API base URLs and endpoint constants
├── Controllers/
│ └── StreamProxyController.cs # HLS proxy endpoints (master/variant/segment)
├── Services/
│ ├── StreamUrlResolver.cs # HLS stream resolution & authentication
│ ├── MetadataCache.cs # Caching layer
│ ├── ContentExpirationService.cs # Expiration management
│ ├── ContentRefreshService.cs # Content discovery
│ └── CategoryService.cs # Topic/category management
│ ├── Interfaces/ # Service contracts
│ │ ├── IStreamProxyService.cs
│ │ ├── IStreamUrlResolver.cs
│ │ ├── IMediaCompositionFetcher.cs
│ │ ├── IMediaSourceFactory.cs
│ │ ├── IMetadataCache.cs
│ │ ├── IContentRefreshService.cs
│ │ ├── IContentExpirationService.cs
│ │ └── ICategoryService.cs
│ ├── StreamProxyService.cs # HLS proxy: auth, manifest rewriting, segments
│ ├── StreamUrlResolver.cs # Stream selection & Akamai authentication
│ ├── MediaCompositionFetcher.cs # Cached API fetcher
│ ├── MediaSourceFactory.cs # Jellyfin MediaSourceInfo builder
│ ├── MetadataCache.cs # Thread-safe ConcurrentDictionary cache
│ ├── ContentExpirationService.cs
│ ├── ContentRefreshService.cs
│ └── CategoryService.cs
├── Providers/
│ ├── SRFSeriesProvider.cs # Series metadata
│ ├── SRFEpisodeProvider.cs # Episode metadata
│ ├── SRFImageProvider.cs # Image fetching
│ └── SRFMediaProvider.cs # Playback URLs
│ ├── SRFSeriesProvider.cs # Series metadata
│ ├── SRFEpisodeProvider.cs # Episode metadata
│ ├── SRFImageProvider.cs # Image fetching
│ └── SRFMediaProvider.cs # Playback URLs
├── Utilities/
│ ├── Extensions.cs # BusinessUnit.ToLowerString() extension
│ ├── MimeTypeHelper.cs # Content-type detection
│ ├── PlaceholderImageGenerator.cs
│ └── UrnHelper.cs # URN parsing utilities
├── ScheduledTasks/
│ ├── ContentRefreshTask.cs # Periodic content refresh
│ └── ExpirationCheckTask.cs # Periodic expiration check
├── ServiceRegistrator.cs # DI registration
└── Plugin.cs # Main plugin entry point
│ ├── ContentRefreshTask.cs # Periodic content refresh
│ └── ExpirationCheckTask.cs # Periodic expiration check
├── ServiceRegistrator.cs # DI registration
└── Plugin.cs # Main plugin entry point
```
### Key Components
1. **API Client**: Handles all HTTP requests to SRF Integration Layer and Play v3 API
2. **Channel**: SRF Play channel with Latest, Trending, and Live Sports folders
3. **Stream Resolver**: Extracts and selects optimal HLS streams with Akamai authentication
4. **Configuration**: User-configurable settings via Jellyfin dashboard
5. **Metadata Cache**: Thread-safe caching with dynamic TTL for livestreams
6. **Content Providers**: Jellyfin integration for series, episodes, images, and media sources
7. **Scheduled Tasks**: Automatic content refresh and expiration management
8. **Service Layer**: Content discovery, expiration handling, stream resolution, and category management
1. **API Client** (`SRFApiClient`): HTTP requests to SRF Integration Layer and Play v3 API, with proxy support
2. **Channel** (`SRFPlayChannel`): SRF Play channel with Latest, Trending, and Live Sports folders
3. **Stream Proxy** (`StreamProxyService` + `StreamProxyController`): HLS proxy that handles Akamai token auth, manifest URL rewriting, deferred authentication, and token refresh for both VOD and livestreams
4. **Stream Resolver** (`StreamUrlResolver`): Selects optimal HLS stream by quality preference, filters DRM content
5. **Metadata Cache** (`MetadataCache`): Thread-safe `ConcurrentDictionary` cache with dynamic TTL for livestreams
6. **Media Composition Fetcher** (`MediaCompositionFetcher`): Cached wrapper around API client for media composition requests
7. **Content Providers** (`SRFSeriesProvider`, `SRFEpisodeProvider`, `SRFImageProvider`, `SRFMediaProvider`): Jellyfin integration for series, episodes, images, and media sources
8. **Scheduled Tasks**: Automatic content refresh and expiration management
9. **Utilities**: Business unit extensions, MIME type helpers, URN parsing, placeholder image generation
## Important Notes
@@ -241,7 +292,12 @@ The plugin includes:
### Contributing
Contributions welcome!
Contributions welcome! This project is hosted on a self-hosted [Gitea](https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay) instance.
**You don't need a separate account** — you can sign in with your existing GitHub account. On the [sign-in page](https://gitea.tourolle.paris/user/login), choose **"Sign in with GitHub"** to register and log in via GitHub OAuth. Once signed in, you can:
- **Raise issues** — report bugs or request features on the [issue tracker](https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/issues)
- **Contribute code** — fork the repository, push a branch, and open a pull request
## License
@@ -249,7 +305,9 @@ See LICENSE file for details.
## Acknowledgments
This plugin was developed with inspiration from the excellent [Kodi SRG SSR addon](https://github.com/goggle/script.module.srgssr) by [@goggle](https://github.com/goggle). The Kodi addon served as a fantastic reference for understanding the SRG SSR API structure, authentication mechanisms, and handling of scheduled livestreams.
This plugin was developed partly using [Claude Code](https://docs.anthropic.com/en/docs/claude-code) by Anthropic.
Inspired by the excellent [Kodi SRG SSR addon](https://github.com/goggle/script.module.srgssr) by [@goggle](https://github.com/goggle), which served as a fantastic reference for understanding the SRG SSR API structure, authentication mechanisms, and handling of scheduled livestreams.
## References
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
---
name: "SRFPlay"
guid: "eb5d7894-8eef-4b36-aa6f-5d124e828ce1"
guid: "a4b12f86-8c3d-4e9a-b7f2-1d5e6c8a9b4f"
version: "1.0.0.0"
targetAbi: "10.9.0.0"
framework: "net8.0"
+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 -->
+93
View File
@@ -0,0 +1,93 @@
[
{
"guid": "a4b12f86-8c3d-4e9a-b7f2-1d5e6c8a9b4f",
"name": "SRF Play (Nightly)",
"description": "NIGHTLY/UNSTABLE builds of the SRF Play plugin for Jellyfin. Built automatically from the master branch on every push. Expect bugs. For stable releases use the regular SRF Play repository instead.",
"overview": "Nightly builds of SRF Play for Jellyfin",
"owner": "dtourolle",
"category": "Live TV",
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
"versions": [
{
"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)",
"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)",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260627.185.zip",
"checksum": "577af725ca202bec555bf51905b75913",
"timestamp": "2026-06-27T09:30:57Z"
}
]
}
]
+144
View File
@@ -8,6 +8,150 @@
"category": "Live TV",
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
"versions": [
{
"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",
"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",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.0.0.zip",
"checksum": "2ed3281990b56fbfb5f75a1ee82a2f55",
"timestamp": "2026-06-27T09:01:57Z"
},
{
"version": "1.0.32",
"changelog": "Release 1.0.32",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.32/srfplay_1.0.32.0.zip",
"checksum": "82f53848f2e2b15a35ea731ee69ee402",
"timestamp": "2026-06-27T07:16:38Z"
},
{
"version": "1.0.31",
"changelog": "Release 1.0.31",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.31/srfplay_1.0.31.0.zip",
"checksum": "4e499de15687e6e328ddca41c6192485",
"timestamp": "2026-06-27T06:54:01Z"
},
{
"version": "1.0.30",
"changelog": "Release 1.0.30",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.30/srfplay_1.0.30.0.zip",
"checksum": "06731df9ba3d2dab53885c9c8ac95fa6",
"timestamp": "2026-05-03T16:44:57Z"
},
{
"version": "1.0.29",
"changelog": "Release 1.0.29",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.29/srfplay_1.0.29.0.zip",
"checksum": "fb745388e64299497262d9ad370d8823",
"timestamp": "2026-05-03T16:07:24Z"
},
{
"version": "1.0.27",
"changelog": "Release 1.0.27",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.27/srfplay_1.0.27.0.zip",
"checksum": "1e15e35452f7b82bf74d8c3560c15949",
"timestamp": "2026-03-07T16:40:17Z"
},
{
"version": "1.0.25",
"changelog": "Release 1.0.25",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.25/srfplay_1.0.25.0.zip",
"checksum": "5e4599cfeee7e0845a1be30ec288cc0b",
"timestamp": "2026-03-07T15:11:52Z"
},
{
"version": "1.0.24",
"changelog": "Release 1.0.24",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.24/srfplay_1.0.24.0.zip",
"checksum": "f54dfb8cd9b555471859ffc89c35fb90",
"timestamp": "2026-03-07T14:55:13Z"
},
{
"version": "1.0.23",
"changelog": "Release 1.0.23",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.23/srfplay_1.0.23.0.zip",
"checksum": "cd98644c758c84e2759699ea1da5a716",
"timestamp": "2026-02-28T12:13:10Z"
},
{
"version": "1.0.22",
"changelog": "Release 1.0.22",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.22/srfplay_1.0.22.0.zip",
"checksum": "08177ed8edb4b4cf2441bc808b9860bb",
"timestamp": "2026-02-28T11:36:30Z"
},
{
"version": "1.0.16",
"changelog": "Release 1.0.16",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.16/srfplay_1.0.16.0.zip",
"checksum": "0b3a142dd6cea1f00855bcb11bcd847c",
"timestamp": "2026-01-17T20:31:09Z"
},
{
"version": "1.0.15",
"changelog": "Release 1.0.15",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.15/srfplay_1.0.15.0.zip",
"checksum": "3140ef53a7460d7ce52daf48bbb37e4d",
"timestamp": "2026-01-17T09:54:39Z"
},
{
"version": "1.0.14",
"changelog": "Release 1.0.14",
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"