Compare commits

..
25 Commits
Author SHA1 Message Date
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
43 changed files with 1749 additions and 451 deletions
+24 -1
View File
@@ -36,6 +36,29 @@ jobs:
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 "label=${LABEL}" >> $GITHUB_OUTPUT
echo "Build version: ${VERSION} (${LABEL})"
- name: Set build version
working-directory: build-${{ github.run_id }}
run: |
sed -i "s/^version:.*/version: \"${{ steps.version.outputs.version }}\"/" build.yaml
- name: Build solution
working-directory: build-${{ github.run_id }}
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained /m:1
@@ -59,7 +82,7 @@ jobs:
- name: Upload build artifact
uses: actions/upload-artifact@v3
with:
name: jellyfin-srfplay-plugin
name: srfplay-${{ steps.version.outputs.label }}-${{ steps.version.outputs.version }}
path: build-${{ github.run_id }}/${{ steps.jprm.outputs.artifact }}
retention-days: 30
if-no-files-found: error
+39 -16
View File
@@ -1,4 +1,4 @@
name: 'Latest Release'
name: 'Nightly Build'
on:
push:
@@ -7,9 +7,10 @@ on:
paths-ignore:
- '**/*.md'
- 'manifest.json'
- 'manifest-nightly.json'
jobs:
latest-release:
nightly-build:
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
@@ -20,6 +21,24 @@ jobs:
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.
DATE=$(date -u +"%Y%m%d")
VERSION="1.0.${DATE}.${{ github.run_number }}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "Nightly version: ${VERSION}"
- name: Set build version
working-directory: build-${{ github.run_id }}
run: |
VERSION="${{ steps.version.outputs.version }}"
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
cat build.yaml
- name: Restore dependencies
working-directory: build-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
@@ -52,7 +71,7 @@ jobs:
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
echo "Checksum: ${CHECKSUM}"
- name: Delete existing latest release
- name: Delete existing nightly release
working-directory: build-${{ github.run_id }}
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -71,7 +90,7 @@ jobs:
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG}" | jq -r '.id')
echo "Deleting existing latest release (ID: ${RELEASE_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}"
@@ -81,7 +100,7 @@ jobs:
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/tags/${TAG}" || true
- name: Create latest release
- name: Create nightly release
working-directory: build-${{ github.run_id }}
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -89,12 +108,14 @@ jobs:
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 "Latest Build" --arg body "SRFPlay Jellyfin Plugin latest build from master." '{tag_name: $tag, name: $name, body: $body, target_commitish: "master", draft: false, prerelease: true}')")
-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')
@@ -124,9 +145,9 @@ jobs:
--data-binary "@build.yaml" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=build.yaml"
echo "Latest release updated successfully!"
echo "Nightly release updated successfully!"
- name: Update manifest.json
- name: Update manifest-nightly.json
working-directory: build-${{ github.run_id }}
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -134,6 +155,7 @@ jobs:
REPO_OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}"
GITEA_URL="${{ github.server_url }}"
VERSION="${{ steps.version.outputs.version }}"
CHECKSUM="${{ steps.checksum.outputs.checksum }}"
ARTIFACT_NAME="${{ steps.jprm.outputs.artifact_name }}"
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
@@ -145,13 +167,10 @@ jobs:
git fetch origin master
git checkout master
# Remove existing "latest" entry if present, then prepend new one
jq --arg url "$DOWNLOAD_URL" 'if .[0].versions[0].changelog == "Latest Build" then .[0].versions = .[0].versions[1:] else . end' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
NEW_VERSION=$(cat <<EOF
{
"version": "0.0.0.0",
"changelog": "Latest Build",
"version": "${VERSION}",
"changelog": "Nightly build ${VERSION} (${SHORT_SHA})",
"targetAbi": "10.9.0.0",
"sourceUrl": "${DOWNLOAD_URL}",
"checksum": "${CHECKSUM}",
@@ -160,9 +179,13 @@ jobs:
EOF
)
jq --argjson newver "${NEW_VERSION}" '.[0].versions = [$newver] + .[0].versions' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
git add manifest.json
git commit -m "Update manifest.json for latest build (${SHORT_SHA})" || echo "No changes to commit"
# Prepend the new build and keep only the most recent 5 nightlies.
jq --argjson newver "${NEW_VERSION}" \
'.[0].versions = ([$newver] + .[0].versions)[0:5]' \
manifest-nightly.json > manifest.tmp && mv manifest.tmp manifest-nightly.json
git add manifest-nightly.json
git commit -m "Nightly: ${VERSION} (${SHORT_SHA})" || echo "No changes to commit"
git push origin master
- name: Cleanup
+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>
@@ -20,6 +20,13 @@ public class RecordingEntry
[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>
@@ -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>();
}
+18 -4
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>
@@ -315,7 +329,7 @@ public class SRFApiClient : IDisposable
{
try
{
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
var baseUrl = BuildPlayV3BaseUrl(businessUnit);
var url = $"{baseUrl}{endpoint}";
_logger.LogInformation("Fetching all {Endpoint} for business unit: {BusinessUnit} from URL: {Url}", endpoint, businessUnit, url);
@@ -352,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);
@@ -392,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";
}
@@ -7,6 +7,7 @@ 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;
@@ -22,11 +23,12 @@ 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;
@@ -35,7 +37,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
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>
@@ -44,7 +46,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
/// <param name="categoryService">The category service (optional).</param>
/// <param name="apiClientFactory">The API client factory.</param>
/// <param name="recordingService">The recording service.</param>
public SRFPlayChannel(
protected SrgChannelBase(
ILoggerFactory loggerFactory,
IContentRefreshService contentRefreshService,
IStreamUrlResolver streamResolver,
@@ -53,7 +55,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
ISRFApiClientFactory apiClientFactory,
IRecordingService recordingService)
{
_logger = loggerFactory.CreateLogger<SRFPlayChannel>();
_logger = loggerFactory.CreateLogger<SrgChannelBase>();
_contentRefreshService = contentRefreshService;
_streamResolver = streamResolver;
_mediaSourceFactory = mediaSourceFactory;
@@ -66,14 +68,24 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
_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
@@ -114,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)
{
@@ -198,7 +211,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
{
try
{
var businessUnit = config.BusinessUnit.ToLowerString();
var businessUnit = Unit.ToLowerString();
var topics = await _categoryService.GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
foreach (var topic in topics.Where(t => !string.IsNullOrEmpty(t.Id)))
@@ -239,24 +252,23 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
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.ToLowerString() ?? "srf";
var businessUnit = Unit.ToLowerString();
using var apiClient = _apiClientFactory.CreateClient();
var scheduledLivestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
@@ -307,6 +319,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
private List<ChannelItemInfo> GetRecordingItems()
{
var items = new List<ChannelItemInfo>();
var unit = Unit.ToLowerString();
var recordings = _recordingService.GetRecordings(RecordingState.Completed);
foreach (var recording in recordings)
@@ -316,6 +329,15 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
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}";
@@ -368,9 +390,8 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
try
{
var config = Plugin.Instance?.Configuration;
var topicId = folderId.Substring("category_".Length);
var businessUnit = config?.BusinessUnit.ToLowerString() ?? "srf";
var businessUnit = Unit.ToLowerString();
var shows = await _categoryService.GetShowsByTopicAsync(topicId, businessUnit, 20, cancellationToken).ConfigureAwait(false);
var urns = new List<string>();
@@ -472,7 +493,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
var timeKey = timeBucket.ToString("yyyy-MM-dd-HH-mm", CultureInfo.InvariantCulture);
var recordingCount = _recordingService.GetRecordings(RecordingState.Completed).Count;
return $"{config?.BusinessUnit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{timeKey}_rec{recordingCount}";
return $"{Unit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{timeKey}_rec{recordingCount}";
}
private async Task<List<ChannelItemInfo>> ConvertUrnsToChannelItems(List<string> urns, CancellationToken cancellationToken)
@@ -614,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; }
@@ -161,4 +168,47 @@ public class PluginConfiguration : BasePluginConfiguration
/// Gets or sets the output directory for sport livestream recordings.
/// </summary>
public string RecordingOutputPath { get; set; } = string.Empty;
/// <summary>
/// Gets or sets how many segments back from the live edge livestream playback should start.
/// Injected as <c>#EXT-X-START:TIME-OFFSET=-(N * targetDuration)</c> into the live media
/// playlist. This keeps the start point out of the volatile live edge, which on Android TV
/// (ExoPlayer) otherwise causes stalling/jumping until a manual skip. RFC 8216 requires the
/// offset to stay at least 3 target durations from the edge, so values below 3 are clamped.
/// Set to 0 to disable injection entirely.
/// </summary>
public int LiveStartSegmentsBack { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the "Clean Up SRF Play Continue Watching" task
/// removes stale resume points. When false the task inspects nothing and clears nothing.
/// </summary>
public bool CleanUpResumePoints { get; set; }
/// <summary>
/// Gets or sets a value indicating whether resume points for livestreams are cleared.
/// A livestream has no meaningful resume position, so stopping one otherwise leaves it
/// pinned in "Continue Watching" forever. When enabled the position is also cleared
/// immediately on playback stop, not just by the scheduled task.
/// </summary>
public bool ClearLiveStreamResumePoints { get; set; }
/// <summary>
/// Gets or sets the age in days after which an untouched resume point is cleared.
/// Measured from the last time the item was played. Set to 0 to disable the age rule.
/// </summary>
public int ResumePointMaxAgeDays { get; set; }
/// <summary>
/// Gets or sets the minimum resume position in seconds. Anything at or below this is
/// treated as an accidental start rather than something worth resuming.
/// Set to 0 to disable the rule.
/// </summary>
public int ResumePointMinPositionSeconds { get; set; }
/// <summary>
/// Gets or sets the percentage of runtime at or beyond which playback counts as finished.
/// Only applied to items with a known runtime. Set to 0 to disable the rule.
/// </summary>
public int ResumePointCompletedPercent { get; set; }
}
@@ -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>
@@ -96,6 +82,47 @@
<input id="RecordingOutputPath" name="RecordingOutputPath" type="text" is="emby-input" placeholder="e.g., /media/recordings/srf" />
<div class="fieldDescription">Directory where sport livestream recordings will be saved (requires ffmpeg)</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="LiveStartSegmentsBack">Live Start Offset (segments)</label>
<input id="LiveStartSegmentsBack" name="LiveStartSegmentsBack" type="number" is="emby-input" min="0" max="20" />
<div class="fieldDescription">How many segments back from the live edge livestreams start playing. Fixes jumpy/stalling playback at the start on Android TV. Minimum 3 (RFC requirement); 0 disables it. Default 3.</div>
</div>
<br />
<h2>Continue Watching Cleanup</h2>
<div class="fieldDescription" style="margin-bottom: 1em;">
Livestreams and abandoned playback otherwise stay pinned in every user's
&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">
@@ -114,6 +141,17 @@
</div>
</form>
<br />
<h2>Continue Watching Maintenance</h2>
<div class="fieldDescription">Run the cleanup right now instead of waiting for the daily task. Only SRF Play items are affected.</div>
<div id="resumeCleanupResult" style="margin: 10px 0;"></div>
<button is="emby-button" type="button" class="raised emby-button" onclick="SRFPlayMaintenance.cleanupResumePoints(false)" style="margin-bottom: 20px;">
<span>Clean Up Stale Entries Now</span>
</button>
<button is="emby-button" type="button" class="raised emby-button" onclick="SRFPlayMaintenance.cleanupResumePoints(true)" style="margin-bottom: 20px;">
<span>Clear All SRF Play Entries</span>
</button>
<br />
<h2>Sport Livestream Recordings</h2>
@@ -149,7 +187,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;
@@ -163,6 +200,12 @@
document.querySelector('#ProxyPassword').value = config.ProxyPassword || '';
document.querySelector('#PublicServerUrl').value = config.PublicServerUrl || '';
document.querySelector('#RecordingOutputPath').value = config.RecordingOutputPath || '';
document.querySelector('#LiveStartSegmentsBack').value = config.LiveStartSegmentsBack != null ? config.LiveStartSegmentsBack : 3;
document.querySelector('#CleanUpResumePoints').checked = config.CleanUpResumePoints !== false;
document.querySelector('#ClearLiveStreamResumePoints').checked = config.ClearLiveStreamResumePoints !== false;
document.querySelector('#ResumePointMaxAgeDays').value = config.ResumePointMaxAgeDays != null ? config.ResumePointMaxAgeDays : 30;
document.querySelector('#ResumePointMinPositionSeconds').value = config.ResumePointMinPositionSeconds != null ? config.ResumePointMinPositionSeconds : 60;
document.querySelector('#ResumePointCompletedPercent').value = config.ResumePointCompletedPercent != null ? config.ResumePointCompletedPercent : 92;
Dashboard.hideLoadingMsg();
// Load recordings UI
@@ -175,7 +218,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);
@@ -189,6 +231,12 @@
config.ProxyPassword = document.querySelector('#ProxyPassword').value;
config.PublicServerUrl = document.querySelector('#PublicServerUrl').value;
config.RecordingOutputPath = document.querySelector('#RecordingOutputPath').value;
config.LiveStartSegmentsBack = parseInt(document.querySelector('#LiveStartSegmentsBack').value) || 0;
config.CleanUpResumePoints = document.querySelector('#CleanUpResumePoints').checked;
config.ClearLiveStreamResumePoints = document.querySelector('#ClearLiveStreamResumePoints').checked;
config.ResumePointMaxAgeDays = parseInt(document.querySelector('#ResumePointMaxAgeDays').value) || 0;
config.ResumePointMinPositionSeconds = parseInt(document.querySelector('#ResumePointMinPositionSeconds').value) || 0;
config.ResumePointCompletedPercent = parseInt(document.querySelector('#ResumePointCompletedPercent').value) || 0;
ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
});
@@ -198,6 +246,40 @@
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: { 'X-Emby-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',
@@ -398,5 +480,3 @@
};
</script>
</div>
</body>
</html>
@@ -1,66 +1,59 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SRF Sport Recordings</title>
<div id="SRFPlayRecordingsPage" data-role="page" class="page type-interior pluginConfigurationPage"
data-require="emby-button,emby-input">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #1a1a2e; color: #e0e0e0; padding: 20px; }
h1 { color: #fff; margin-bottom: 8px; font-size: 1.8em; }
h2 { color: #ccc; margin: 24px 0 12px; font-size: 1.3em; border-bottom: 1px solid #333; padding-bottom: 6px; }
.subtitle { color: #999; margin-bottom: 20px; font-size: 0.9em; }
table { width: 100%; border-collapse: collapse; margin: 10px 0 20px; }
th { text-align: left; padding: 10px 8px; border-bottom: 2px solid #444; color: #aaa; font-size: 0.85em; text-transform: uppercase; }
td { padding: 10px 8px; border-bottom: 1px solid #2a2a3e; }
tr:hover { background: #2a2a3e; }
.btn { padding: 6px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 0.85em; color: #fff; transition: opacity 0.2s; }
.btn:hover { opacity: 0.85; }
.btn-record { background: #4CAF50; }
.btn-stop { background: #FF9800; }
.btn-cancel { background: #9E9E9E; }
.btn-delete { background: #f44336; }
.btn-refresh { background: #2196F3; margin-bottom: 16px; }
.status { padding: 3px 8px; border-radius: 3px; font-size: 0.8em; font-weight: 600; }
.status-scheduled { background: #1565C0; }
.status-waiting { background: #E65100; }
.status-recording { background: #2E7D32; }
.status-failed { background: #C62828; }
.msg { padding: 12px; color: #999; font-style: italic; }
.error { color: #f44; }
.login-form { max-width: 400px; margin: 60px auto; }
.login-form input { width: 100%; padding: 10px; margin: 8px 0; background: #2a2a3e; border: 1px solid #444; color: #fff; border-radius: 4px; font-size: 1em; }
.login-form .btn { width: 100%; padding: 12px; font-size: 1em; margin-top: 12px; }
@media (max-width: 600px) { body { padding: 10px; } td, th { padding: 6px 4px; font-size: 0.85em; } }
#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>
</head>
<body>
<div id="loginSection" style="display:none;">
<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="subtitle">Sign in to your Jellyfin server</p>
<input type="text" id="loginServer" placeholder="Server URL (e.g. http://192.168.1.50:8096)" />
<input type="text" id="loginUser" placeholder="Username" />
<input type="password" id="loginPass" placeholder="Password" />
<button class="btn btn-record" onclick="SRFRec.login()">Sign In</button>
<p id="loginError" class="error" style="margin-top:10px;"></p>
<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="mainSection" style="display:none;">
<div id="srfrecMainSection" style="display:none;">
<h1>SRF Sport Recordings</h1>
<p class="subtitle">Browse upcoming sport livestreams and schedule recordings. <span id="userInfo"></span></p>
<p class="fieldDescription">Browse upcoming sport livestreams and schedule recordings. <span id="srfrecUserInfo"></span></p>
<h2>Upcoming Sport Livestreams</h2>
<button class="btn btn-refresh" onclick="SRFRec.loadSchedule()">Refresh Schedule</button>
<div id="scheduleContainer"><p class="msg">Loading schedule...</p></div>
<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="activeRecordingsContainer"><p class="msg">Loading...</p></div>
<div id="srfrecActiveRecordingsContainer"><p class="srfrec-msg">Loading...</p></div>
<h2>Completed Recordings</h2>
<button class="btn btn-refresh" onclick="SRFRec.loadRecordings()">Refresh</button>
<div id="completedRecordingsContainer"><p class="msg">Loading...</p></div>
<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>
@@ -91,9 +84,9 @@
}
// Show login form
var serverInput = document.getElementById('loginServer');
var serverInput = document.getElementById('srfrecLoginServer');
serverInput.value = window.location.origin;
document.getElementById('loginSection').style.display = 'block';
document.getElementById('srfrecLoginSection').style.display = 'block';
},
verifySession: function(session) {
@@ -102,24 +95,24 @@
headers: { 'X-Emby-Token': this.token }
}).then(function(r) {
if (r.ok) {
document.getElementById('userInfo').textContent = '(Server: ' + self.serverUrl + ')';
document.getElementById('srfrecUserInfo').textContent = '(Server: ' + self.serverUrl + ')';
self.showMain();
} else {
localStorage.removeItem('srfRecSession');
document.getElementById('loginSection').style.display = 'block';
document.getElementById('srfrecLoginSection').style.display = 'block';
}
}).catch(function() {
localStorage.removeItem('srfRecSession');
document.getElementById('loginSection').style.display = 'block';
document.getElementById('srfrecLoginSection').style.display = 'block';
});
},
login: function() {
var self = this;
var server = document.getElementById('loginServer').value.replace(/\/+$/, '');
var user = document.getElementById('loginUser').value;
var pass = document.getElementById('loginPass').value;
var errorEl = document.getElementById('loginError');
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', {
@@ -142,7 +135,7 @@
token: data.AccessToken,
userName: data.User.Name
}));
document.getElementById('userInfo').textContent = '(Signed in as ' + data.User.Name + ')';
document.getElementById('srfrecUserInfo').textContent = '(Signed in as ' + data.User.Name + ')';
self.showMain();
})
.catch(function(err) {
@@ -151,8 +144,8 @@
},
showMain: function() {
document.getElementById('loginSection').style.display = 'none';
document.getElementById('mainSection').style.display = 'block';
document.getElementById('srfrecLoginSection').style.display = 'none';
document.getElementById('srfrecMainSection').style.display = 'block';
this.loadSchedule();
this.loadRecordings();
},
@@ -175,14 +168,14 @@
},
loadSchedule: function() {
var container = document.getElementById('scheduleContainer');
container.innerHTML = '<p class="msg">Loading schedule...</p>';
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="msg">No upcoming sport livestreams found.</p>';
container.innerHTML = '<p class="srfrec-msg">No upcoming sport livestreams found.</p>';
return;
}
@@ -194,7 +187,7 @@
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 class="btn btn-record" onclick="SRFRec.scheduleRecording(\'' + encodeURIComponent(p.urn) + '\')">Record</button></td>';
html += '<td><button type="button" class="btn btn-record" onclick="SRFRec.scheduleRecording(\'' + encodeURIComponent(p.urn) + '\')">Record</button></td>';
html += '</tr>';
});
@@ -202,7 +195,7 @@
container.innerHTML = html;
})
.catch(function(err) {
container.innerHTML = '<p class="error">Error loading schedule: ' + err.message + '</p>';
container.innerHTML = '<p class="srfrec-error">Error loading schedule: ' + err.message + '</p>';
});
},
@@ -229,8 +222,8 @@
},
loadActiveRecordings: function() {
var container = document.getElementById('activeRecordingsContainer');
container.innerHTML = '<p class="msg">Loading...</p>';
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(); })
@@ -241,7 +234,7 @@
});
if (active.length === 0) {
container.innerHTML = '<p class="msg">No scheduled or active recordings.</p>';
container.innerHTML = '<p class="srfrec-msg">No scheduled or active recordings.</p>';
return;
}
@@ -265,9 +258,9 @@
html += '<td>' + SRFRec.formatDate(r.validFrom) + '</td>';
html += '<td>';
if (r.state === 2 || r.state === 'Recording') {
html += '<button class="btn btn-stop" onclick="SRFRec.stopRecording(\'' + r.id + '\')">Stop</button>';
html += '<button type="button" class="btn btn-stop" onclick="SRFRec.stopRecording(\'' + r.id + '\')">Stop</button>';
} else {
html += '<button class="btn btn-cancel" onclick="SRFRec.cancelRecording(\'' + r.id + '\')">Cancel</button>';
html += '<button type="button" class="btn btn-cancel" onclick="SRFRec.cancelRecording(\'' + r.id + '\')">Cancel</button>';
}
html += '</td></tr>';
});
@@ -276,19 +269,19 @@
container.innerHTML = html;
})
.catch(function(err) {
container.innerHTML = '<p class="error">Error: ' + err.message + '</p>';
container.innerHTML = '<p class="srfrec-error">Error: ' + err.message + '</p>';
});
},
loadCompletedRecordings: function() {
var container = document.getElementById('completedRecordingsContainer');
container.innerHTML = '<p class="msg">Loading...</p>';
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="msg">No completed recordings.</p>';
container.innerHTML = '<p class="srfrec-msg">No completed recordings.</p>';
return;
}
@@ -300,7 +293,7 @@
html += '<td>' + (r.title || 'Unknown') + '</td>';
html += '<td>' + SRFRec.formatDate(r.recordingStartedAt) + '</td>';
html += '<td>' + SRFRec.formatSize(r.fileSizeBytes) + '</td>';
html += '<td><button class="btn btn-delete" onclick="SRFRec.deleteRecording(\'' + r.id + '\')">Delete</button></td>';
html += '<td><button type="button" class="btn btn-delete" onclick="SRFRec.deleteRecording(\'' + r.id + '\')">Delete</button></td>';
html += '</tr>';
});
@@ -308,7 +301,7 @@
container.innerHTML = html;
})
.catch(function(err) {
container.innerHTML = '<p class="error">Error: ' + err.message + '</p>';
container.innerHTML = '<p class="srfrec-error">Error: ' + err.message + '</p>';
});
},
@@ -338,7 +331,16 @@
}
};
(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>
</body>
</html>
</div>
@@ -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));
}
}
@@ -40,6 +40,7 @@ public class RecordingController : ControllerBase
/// </summary>
/// <returns>The recording manager HTML page.</returns>
[HttpGet("Page")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult GetRecordingPage()
@@ -225,7 +225,8 @@ public class StreamProxyController : ControllerBase
queryParams = string.Empty;
}
var rewrittenContent = _proxyService.RewriteVariantManifestUrls(manifestContent, baseProxyUrl, queryParams);
var isLiveStream = _proxyService.GetStreamMetadata(actualItemId)?.IsLiveStream ?? false;
var rewrittenContent = _proxyService.RewriteVariantManifestUrls(manifestContent, baseProxyUrl, queryParams, isLiveStream);
// Set cache headers based on stream type (live vs VOD)
// Variant manifests use stricter no-cache for live streams
@@ -33,6 +33,11 @@
<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 -9
View File
@@ -45,16 +45,13 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
Name = Name,
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace)
},
new PluginPageInfo
{
Name = "SRF Play Recordings",
DisplayName = "SRF Sport Recordings",
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.recordingPage.html", GetType().Namespace),
EnableInMainMenu = true,
MenuSection = "Live TV",
MenuIcon = "fiber_smart_record"
}
// 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).
];
}
}
@@ -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
// Refresh content for every business unit (one channel per unit).
var units = System.Enum.GetValues<Configuration.BusinessUnit>();
foreach (var unit in units)
{
var businessUnit = unit.ToLowerString();
if (config.EnableLatestContent)
{
_logger.LogInformation("Refreshing latest content");
_logger.LogInformation("Refreshing latest content for {BusinessUnit}", businessUnit);
progress?.Report(25);
await _contentRefreshService.RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
await _contentRefreshService.RefreshLatestContentAsync(businessUnit, cancellationToken).ConfigureAwait(false);
}
// Refresh trending content
if (config.EnableTrendingContent)
{
_logger.LogInformation("Refreshing trending content");
_logger.LogInformation("Refreshing trending content for {BusinessUnit}", businessUnit);
progress?.Report(75);
await _contentRefreshService.RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
await _contentRefreshService.RefreshTrendingContentAsync(businessUnit, cancellationToken).ConfigureAwait(false);
}
}
progress?.Report(100);
@@ -0,0 +1,87 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
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[]
{
new TaskTriggerInfo
{
Type = TaskTriggerInfo.TriggerDaily,
TimeOfDayTicks = TimeSpan.FromHours(4).Ticks
}
};
}
}
+14 -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>();
@@ -48,8 +53,15 @@ public class ServiceRegistrator : IPluginServiceRegistrator
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");
}
@@ -34,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)
@@ -46,7 +47,7 @@ public class ContentRefreshService : IContentRefreshService
}
return await FetchVideosFromShowsAsync(
config.BusinessUnit.ToLowerString(),
businessUnit,
minEpisodeCount: 0,
maxShows: 20,
videosPerShow: 1,
@@ -58,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)
@@ -70,7 +72,7 @@ public class ContentRefreshService : IContentRefreshService
}
return await FetchVideosFromShowsAsync(
config.BusinessUnit.ToLowerString(),
businessUnit,
minEpisodeCount: 10,
maxShows: 15,
videosPerShow: 2,
@@ -12,14 +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);
Task<List<string>> RefreshTrendingContentAsync(string businessUnit, CancellationToken cancellationToken);
}
@@ -0,0 +1,53 @@
using System.Collections.Generic;
using System.Threading;
using Jellyfin.Data.Entities;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using MediaBrowser.Controller.Entities;
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
/// <summary>
/// Removes stale plugin-owned resume points so ended livestreams and abandoned playback
/// stop accumulating in every user's "Continue Watching" row.
/// </summary>
public interface IResumeCleanupService
{
/// <summary>
/// Scans every user's resumable items and clears the resume point of those owned by this
/// plugin that match a staleness rule. Items belonging to other plugins or to the regular
/// library are never touched.
/// </summary>
/// <param name="clearAll">
/// When true every plugin-owned resume point is cleared regardless of the staleness rules.
/// Used by the "Clear all" maintenance action.
/// </param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A summary of what was inspected and cleared.</returns>
ResumeCleanupResult Cleanup(bool clearAll, CancellationToken cancellationToken);
/// <summary>
/// Clears the resume point of a single item for the given users.
/// </summary>
/// <param name="item">The item whose resume point should be reset.</param>
/// <param name="users">The users to clear it for.</param>
/// <param name="reason">A short reason, logged for traceability.</param>
/// <returns>The number of resume points actually cleared.</returns>
int ClearResumePoint(BaseItem item, IEnumerable<User> users, string reason);
/// <summary>
/// Determines whether an item belongs to this plugin (an SRG channel item or a recording).
/// </summary>
/// <param name="item">The item to test.</param>
/// <returns>True if the item is owned by this plugin.</returns>
bool IsPluginItem(BaseItem item);
/// <summary>
/// Clears the resume point if the item is one of this plugin's livestreams and livestream
/// cleanup is enabled. Called right after playback stops so an ended broadcast never reaches
/// "Continue Watching" in the first place, instead of waiting for the nightly task.
/// </summary>
/// <param name="item">The item that just stopped playing.</param>
/// <param name="users">The users the playback was attributed to.</param>
/// <returns>The number of resume points cleared.</returns>
int ClearLiveStreamResumePoint(BaseItem item, IEnumerable<User> users);
}
@@ -71,8 +71,9 @@ public interface IStreamProxyService
/// <param name="manifestContent">The variant manifest content.</param>
/// <param name="baseProxyUrl">The base proxy URL (without query params).</param>
/// <param name="queryParams">Query parameters to append to rewritten URLs (e.g., "?token=abc").</param>
/// <param name="isLiveStream">Whether this is a livestream; if so an EXT-X-START offset is injected.</param>
/// <returns>The rewritten manifest content.</returns>
string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams);
string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams, bool isLiveStream = false);
/// <summary>
/// Cleans up old and expired stream mappings.
@@ -7,6 +7,7 @@ using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Constants;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Utilities;
using MediaBrowser.Controller;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
@@ -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
@@ -0,0 +1,79 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Watches for livestream playback ending and immediately drops the resume point Jellyfin
/// just saved for it. Without this, every livestream a user stops watching sticks in
/// "Continue Watching" until the nightly cleanup task runs.
/// </summary>
public class PlaybackResumeGuard : IHostedService
{
private readonly ILogger<PlaybackResumeGuard> _logger;
private readonly ISessionManager _sessionManager;
private readonly IResumeCleanupService _resumeCleanupService;
/// <summary>
/// Initializes a new instance of the <see cref="PlaybackResumeGuard"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="sessionManager">The session manager raising playback events.</param>
/// <param name="resumeCleanupService">The resume cleanup service.</param>
public PlaybackResumeGuard(
ILogger<PlaybackResumeGuard> logger,
ISessionManager sessionManager,
IResumeCleanupService resumeCleanupService)
{
_logger = logger;
_sessionManager = sessionManager;
_resumeCleanupService = resumeCleanupService;
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
_sessionManager.PlaybackStopped += OnPlaybackStopped;
_logger.LogDebug("PlaybackResumeGuard attached to session manager");
return Task.CompletedTask;
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
_sessionManager.PlaybackStopped -= OnPlaybackStopped;
return Task.CompletedTask;
}
private void OnPlaybackStopped(object? sender, PlaybackStopEventArgs e)
{
// Jellyfin saves the resume point before raising this event, so clearing it here wins.
if (e?.Item == null || e.Users == null || e.Users.Count == 0)
{
return;
}
try
{
var cleared = _resumeCleanupService.ClearLiveStreamResumePoint(e.Item, e.Users);
if (cleared > 0)
{
_logger.LogDebug(
"Dropped {Count} resume point(s) for finished livestream '{Name}'",
cleared,
e.Item.Name);
}
}
catch (Exception ex)
{
// Never let a cleanup failure escape into Jellyfin's playback event pipeline.
_logger.LogError(ex, "Error clearing livestream resume point for '{Name}'", e.Item.Name);
}
}
}
@@ -13,6 +13,7 @@ using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Utilities;
using MediaBrowser.Controller;
using MediaBrowser.Controller.MediaEncoding;
using Microsoft.Extensions.Logging;
@@ -147,19 +148,25 @@ public class RecordingService : IRecordingService, IDisposable
/// <inheritdoc />
public async Task<IReadOnlyList<PlayV3TvProgram>> GetUpcomingScheduleAsync(CancellationToken cancellationToken)
{
var config = Plugin.Instance?.Configuration;
var businessUnit = (config?.BusinessUnit ?? Configuration.BusinessUnit.SRF).ToString().ToLowerInvariant();
var units = System.Enum.GetValues<Configuration.BusinessUnit>();
using var apiClient = _apiClientFactory.CreateClient();
var livestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
if (livestreams == null)
// 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)
{
return Array.Empty<PlayV3TvProgram>();
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 livestreams
return all
.Where(ls => ls.Blocked != true && (ls.ValidTo == null || ls.ValidTo.Value.ToUniversalTime() > DateTime.UtcNow))
.OrderBy(ls => ls.ValidFrom)
.ToList();
@@ -178,9 +185,11 @@ public class RecordingService : IRecordingService, IDisposable
return existing;
}
// Fetch metadata for the URN
// 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 = (config?.BusinessUnit ?? Configuration.BusinessUnit.SRF).ToString().ToLowerInvariant();
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);
@@ -190,6 +199,7 @@ public class RecordingService : IRecordingService, IDisposable
{
Id = Guid.NewGuid().ToString("N"),
Urn = urn,
BusinessUnit = ParseBusinessUnitFromUrn(urn) ?? businessUnit,
Title = program?.Title ?? urn,
Description = program?.Lead ?? program?.Description,
ImageUrl = program?.ImageUrl,
@@ -206,6 +216,24 @@ public class RecordingService : IRecordingService, IDisposable
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)
{
@@ -415,7 +443,7 @@ public class RecordingService : IRecordingService, IDisposable
// Register the stream with the proxy so we can use the proxy URL
var itemId = $"rec_{entry.Id}";
var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" || entry.Urn.Contains("livestream", StringComparison.OrdinalIgnoreCase);
var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" || UrnHelper.IsLivestreamUrn(entry.Urn);
_proxyService.RegisterStreamDeferred(itemId, streamUrl, entry.Urn, isLiveStream);
// Build proxy URL for ffmpeg (use localhost for local access)
@@ -0,0 +1,302 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using Jellyfin.Data.Entities;
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();
foreach (var user in _userManager.Users)
{
cancellationToken.ThrowIfCancellationRequested();
result.UsersChecked++;
var query = new InternalItemsQuery(user)
{
IsResumable = true,
Recursive = true,
EnableTotalRecordCount = false,
DtoOptions = new DtoOptions(false)
};
foreach (var item in _libraryManager.GetItemList(query))
{
cancellationToken.ThrowIfCancellationRequested();
if (!IsPluginItem(item, channelIds))
{
continue;
}
result.Inspected++;
var userData = _userDataManager.GetUserData(user, item);
if (userData == null || userData.PlaybackPositionTicks <= 0)
{
continue;
}
var reason = clearAll ? "clear all" : GetStaleReason(item, userData, config);
if (reason == null)
{
continue;
}
if (ClearResumePoint(item, user, userData, reason))
{
result.Cleared++;
result.ClearedByReason.TryGetValue(reason, out var count);
result.ClearedByReason[reason] = count + 1;
}
}
}
_logger.LogInformation(
"Resume point cleanup finished: {Cleared} of {Inspected} SRF Play resume points cleared across {Users} user(s)",
result.Cleared,
result.Inspected,
result.UsersChecked);
return result;
}
/// <inheritdoc />
public int ClearResumePoint(BaseItem item, IEnumerable<User> users, string reason)
{
ArgumentNullException.ThrowIfNull(item);
ArgumentNullException.ThrowIfNull(users);
var cleared = 0;
foreach (var user in users)
{
var userData = _userDataManager.GetUserData(user, item);
if (userData == null || userData.PlaybackPositionTicks <= 0)
{
continue;
}
if (ClearResumePoint(item, user, userData, reason))
{
cleared++;
}
}
return cleared;
}
/// <inheritdoc />
public bool IsPluginItem(BaseItem item)
{
return IsPluginItem(item, GetPluginChannelIds());
}
/// <inheritdoc />
public int ClearLiveStreamResumePoint(BaseItem item, IEnumerable<User> users)
{
if (item == null || Plugin.Instance?.Configuration.ClearLiveStreamResumePoints != true)
{
return 0;
}
// Only the URN heuristic here - the item is already known, and re-querying the channel
// list on every playback stop would be wasteful.
if (!UrnHelper.IsLivestreamUrn(item.ProviderIds.GetValueOrDefault(SrfProviderId)))
{
return 0;
}
return ClearResumePoint(item, users, "livestream stopped");
}
/// <summary>
/// Determines whether an item belongs to one of this plugin's channels.
/// </summary>
/// <param name="item">The item to test.</param>
/// <param name="channelIds">The set of channel IDs owned by this plugin.</param>
/// <returns>True if the item is owned by this plugin.</returns>
private static bool IsPluginItem(BaseItem item, HashSet<Guid> channelIds)
{
if (item == null)
{
return false;
}
// API-sourced content carries the URN as a provider ID. Recordings do not, so fall back
// to the owning channel, which also covers any item shape added later.
return item.ProviderIds.ContainsKey(SrfProviderId)
|| (!item.ChannelId.Equals(Guid.Empty) && channelIds.Contains(item.ChannelId));
}
/// <summary>
/// Resolves the IDs of the channel entries created by this plugin, one per business unit.
/// </summary>
/// <returns>The set of channel IDs, empty if the channels have not been scanned yet.</returns>
private HashSet<Guid> GetPluginChannelIds()
{
// SrgChannelBase names every channel "{unit} Play".
var names = Enum.GetNames<BusinessUnit>()
.Select(unit => unit + " Play")
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var channels = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.Channel },
EnableTotalRecordCount = false,
DtoOptions = new DtoOptions(false)
});
return channels
.Where(channel => channel.Name != null && names.Contains(channel.Name))
.Select(channel => channel.Id)
.ToHashSet();
}
/// <summary>
/// Decides whether a resume point is stale, and why.
/// </summary>
/// <param name="item">The item the resume point belongs to.</param>
/// <param name="userData">The user data holding the resume position.</param>
/// <param name="config">The plugin configuration supplying the thresholds.</param>
/// <returns>A short reason string, or null when the resume point should be kept.</returns>
private static string? GetStaleReason(BaseItem item, UserItemData userData, PluginConfiguration config)
{
// A livestream has no meaningful position to return to: by the time the user comes back
// the broadcast has moved on or ended. These are the entries that pile up the fastest.
if (config.ClearLiveStreamResumePoints
&& UrnHelper.IsLivestreamUrn(item.ProviderIds.GetValueOrDefault(SrfProviderId)))
{
return "livestream";
}
if (config.ResumePointMaxAgeDays > 0
&& userData.LastPlayedDate.HasValue
&& userData.LastPlayedDate.Value.ToUniversalTime() < DateTime.UtcNow.AddDays(-config.ResumePointMaxAgeDays))
{
return "older than " + config.ResumePointMaxAgeDays.ToString(CultureInfo.InvariantCulture) + " days";
}
if (config.ResumePointMinPositionSeconds > 0
&& userData.PlaybackPositionTicks <= TimeSpan.FromSeconds(config.ResumePointMinPositionSeconds).Ticks)
{
return "barely started";
}
// Runtime is unknown for live and for some API items; without it there is nothing to
// compare the position against, so leave the resume point alone.
if (config.ResumePointCompletedPercent > 0
&& item.RunTimeTicks.HasValue
&& item.RunTimeTicks.Value > 0)
{
var watchedPercent = userData.PlaybackPositionTicks * 100.0 / item.RunTimeTicks.Value;
if (watchedPercent >= config.ResumePointCompletedPercent)
{
return "watched to the end";
}
}
return null;
}
/// <summary>
/// Zeroes the resume position, which removes the item from "Continue Watching" while
/// leaving it unwatched so it can still be played again from the start.
/// </summary>
/// <param name="item">The item to clear.</param>
/// <param name="user">The user to clear it for.</param>
/// <param name="userData">The user data to update.</param>
/// <param name="reason">A short reason, logged for traceability.</param>
/// <returns>True if the resume point was cleared.</returns>
private bool ClearResumePoint(BaseItem item, User user, UserItemData userData, string reason)
{
try
{
userData.PlaybackPositionTicks = 0;
_userDataManager.SaveUserData(
user,
item,
userData,
UserDataSaveReason.UpdateUserData,
CancellationToken.None);
_logger.LogInformation(
"Cleared resume point for '{Name}' (user {User}, reason: {Reason})",
item.Name,
user.Username,
reason);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to clear resume point for '{Name}'", item.Name);
return false;
}
}
}
@@ -900,9 +900,15 @@ public class StreamProxyService : IStreamProxyService
/// <param name="manifestContent">The variant manifest content.</param>
/// <param name="baseProxyUrl">The base proxy URL (without query params).</param>
/// <param name="queryParams">Query parameters to append to rewritten URLs (e.g., "?token=abc").</param>
/// <param name="isLiveStream">Whether this is a livestream; if so an EXT-X-START offset is injected.</param>
/// <returns>The rewritten manifest content.</returns>
public string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams)
public string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams, bool isLiveStream = false)
{
if (isLiveStream)
{
manifestContent = InjectLiveStartOffset(manifestContent);
}
string RewriteUrl(string url)
{
if (url.Contains("://", StringComparison.Ordinal))
@@ -953,6 +959,82 @@ public class StreamProxyService : IStreamProxyService
return result.ToString();
}
/// <summary>
/// Injects an <c>#EXT-X-START:TIME-OFFSET</c> tag into a live media playlist so that players
/// (notably ExoPlayer on Android TV) begin a few segments back from the volatile live edge
/// instead of at the edge itself, which otherwise causes stalling/jumping until a manual skip.
/// The offset is sized relative to the playlist's own <c>EXT-X-TARGETDURATION</c> to stay
/// RFC 8216 compliant (≥ 3 target durations from the edge for live playlists).
/// </summary>
/// <param name="manifestContent">The media playlist content.</param>
/// <returns>The playlist with the tag injected, or unchanged if it is not applicable.</returns>
private string InjectLiveStartOffset(string manifestContent)
{
var segmentsBack = Plugin.Instance?.Configuration?.LiveStartSegmentsBack ?? 3;
if (segmentsBack <= 0)
{
return manifestContent; // Disabled by config.
}
// Only a media playlist (has segments, no variant list) should carry EXT-X-START.
// A master/multivariant playlist or a VOD playlist (#EXT-X-ENDLIST) is left untouched.
if (!manifestContent.Contains("#EXTINF", StringComparison.Ordinal)
|| manifestContent.Contains("#EXT-X-STREAM-INF", StringComparison.Ordinal)
|| manifestContent.Contains("#EXT-X-ENDLIST", StringComparison.Ordinal))
{
return manifestContent;
}
// Don't add a second tag if the source already specified a start point.
if (manifestContent.Contains("#EXT-X-START", StringComparison.Ordinal))
{
_logger.LogDebug("Live playlist already contains #EXT-X-START; leaving as-is");
return manifestContent;
}
var targetDurationMatch = Regex.Match(manifestContent, @"#EXT-X-TARGETDURATION:(\d+(?:\.\d+)?)");
if (!targetDurationMatch.Success
|| !double.TryParse(
targetDurationMatch.Groups[1].Value,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out var targetDuration)
|| targetDuration <= 0)
{
_logger.LogDebug("Could not read EXT-X-TARGETDURATION; skipping EXT-X-START injection");
return manifestContent;
}
// RFC 8216: TIME-OFFSET SHOULD NOT be within 3 target durations of the live edge.
var clampedSegments = Math.Max(3, segmentsBack);
var offsetSeconds = clampedSegments * targetDuration;
var startTag = string.Format(
System.Globalization.CultureInfo.InvariantCulture,
"#EXT-X-START:TIME-OFFSET=-{0:0.###},PRECISE=YES",
offsetSeconds);
_logger.LogInformation(
"Injecting {StartTag} into live playlist (targetDuration={TargetDuration}s, segmentsBack={SegmentsBack})",
startTag,
targetDuration,
clampedSegments);
// Insert after the #EXTM3U header so it sits with the other top-level tags.
var headerIndex = manifestContent.IndexOf("#EXTM3U", StringComparison.Ordinal);
if (headerIndex < 0)
{
return startTag + "\n" + manifestContent;
}
var lineEnd = manifestContent.IndexOf('\n', headerIndex);
if (lineEnd < 0)
{
return manifestContent + "\n" + startTag;
}
return manifestContent[..(lineEnd + 1)] + startTag + "\n" + manifestContent[(lineEnd + 1)..];
}
/// <summary>
/// Cleans up old and expired stream mappings.
/// </summary>
@@ -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);
}
}
+17 -1
View File
@@ -22,6 +22,7 @@ Then install "SRF Play" from the plugin catalog.
- **Live Sports Streaming** - Watch scheduled sports events (skiing, Formula 1, football, tennis, etc.)
- Support for all Swiss broadcasting units (SRF, RTS, RSI, RTR, SWI)
- Automatic content expiration handling
- Continue Watching cleanup — ended livestreams and abandoned playback don't pile up in the resume row
- Latest and trending content discovery
- Quality selection (Auto lets CDN decide, SD prefers 480p/360p, HD prefers 1080p/720p)
- HLS streaming support with Akamai token authentication
@@ -137,6 +138,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).
@@ -270,7 +281,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
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"
+29
View File
@@ -0,0 +1,29 @@
[
{
"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.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"
}
]
}
]
+50 -10
View File
@@ -8,13 +8,61 @@
"category": "Live TV",
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
"versions": [
{
"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": "a58587b0c596992eb059885b20246d21",
"timestamp": "2026-03-07T17:41:15Z"
"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",
@@ -24,14 +72,6 @@
"checksum": "1e15e35452f7b82bf74d8c3560c15949",
"timestamp": "2026-03-07T16:40:17Z"
},
{
"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": "c7e868d23293adcc21d72e735094d9d6",
"timestamp": "2026-03-07T16:28:38Z"
},
{
"version": "1.0.25",
"changelog": "Release 1.0.25",