From 390146e8d46d1e7aaca1c481e59af53a05978dd4 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Wed, 12 Aug 2026 18:42:56 +0200 Subject: [PATCH] Clean up stale Continue Watching entries 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 --- .../Api/Models/ResumeCleanupResult.cs | 29 ++ .../Configuration/PluginConfiguration.cs | 38 +++ .../Configuration/configPage.html | 91 ++++++ .../Controllers/MaintenanceController.cs | 53 +++ .../ScheduledTasks/ResumeCleanupTask.cs | 87 +++++ Jellyfin.Plugin.SRFPlay/ServiceRegistrator.cs | 6 + .../Interfaces/IResumeCleanupService.cs | 53 +++ .../Services/MediaSourceFactory.cs | 3 +- .../Services/PlaybackResumeGuard.cs | 79 +++++ .../Services/RecordingService.cs | 3 +- .../Services/ResumeCleanupService.cs | 302 ++++++++++++++++++ .../Utilities/UrnHelper.cs | 13 + README.md | 11 + 13 files changed, 766 insertions(+), 2 deletions(-) create mode 100644 Jellyfin.Plugin.SRFPlay/Api/Models/ResumeCleanupResult.cs create mode 100644 Jellyfin.Plugin.SRFPlay/Controllers/MaintenanceController.cs create mode 100644 Jellyfin.Plugin.SRFPlay/ScheduledTasks/ResumeCleanupTask.cs create mode 100644 Jellyfin.Plugin.SRFPlay/Services/Interfaces/IResumeCleanupService.cs create mode 100644 Jellyfin.Plugin.SRFPlay/Services/PlaybackResumeGuard.cs create mode 100644 Jellyfin.Plugin.SRFPlay/Services/ResumeCleanupService.cs diff --git a/Jellyfin.Plugin.SRFPlay/Api/Models/ResumeCleanupResult.cs b/Jellyfin.Plugin.SRFPlay/Api/Models/ResumeCleanupResult.cs new file mode 100644 index 0000000..16dea36 --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Api/Models/ResumeCleanupResult.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; + +namespace Jellyfin.Plugin.SRFPlay.Api.Models; + +/// +/// Outcome of a resume point ("Continue Watching") cleanup run. +/// +public class ResumeCleanupResult +{ + /// + /// Gets or sets the number of users whose resume list was inspected. + /// + public int UsersChecked { get; set; } + + /// + /// Gets or sets the number of plugin-owned resume points that were inspected. + /// + public int Inspected { get; set; } + + /// + /// Gets or sets the number of resume points that were cleared. + /// + public int Cleared { get; set; } + + /// + /// Gets the number of cleared resume points grouped by the rule that matched. + /// + public Dictionary ClearedByReason { get; } = new Dictionary(); +} diff --git a/Jellyfin.Plugin.SRFPlay/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.SRFPlay/Configuration/PluginConfiguration.cs index 9352ad5..138b142 100644 --- a/Jellyfin.Plugin.SRFPlay/Configuration/PluginConfiguration.cs +++ b/Jellyfin.Plugin.SRFPlay/Configuration/PluginConfiguration.cs @@ -76,6 +76,11 @@ public class PluginConfiguration : BasePluginConfiguration EnabledTopics = new System.Collections.Generic.List(); GenerateTitleCards = true; LiveStartSegmentsBack = 3; + CleanUpResumePoints = true; + ClearLiveStreamResumePoints = true; + ResumePointMaxAgeDays = 30; + ResumePointMinPositionSeconds = 60; + ResumePointCompletedPercent = 92; } /// @@ -173,4 +178,37 @@ public class PluginConfiguration : BasePluginConfiguration /// Set to 0 to disable injection entirely. /// public int LiveStartSegmentsBack { get; set; } + + /// + /// 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. + /// + public bool CleanUpResumePoints { get; set; } + + /// + /// 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. + /// + public bool ClearLiveStreamResumePoints { get; set; } + + /// + /// 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. + /// + public int ResumePointMaxAgeDays { get; set; } + + /// + /// 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. + /// + public int ResumePointMinPositionSeconds { get; set; } + + /// + /// 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. + /// + public int ResumePointCompletedPercent { get; set; } } diff --git a/Jellyfin.Plugin.SRFPlay/Configuration/configPage.html b/Jellyfin.Plugin.SRFPlay/Configuration/configPage.html index 1e0499c..46f2268 100644 --- a/Jellyfin.Plugin.SRFPlay/Configuration/configPage.html +++ b/Jellyfin.Plugin.SRFPlay/Configuration/configPage.html @@ -88,6 +88,42 @@
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.

+

Continue Watching Cleanup

+
+ Livestreams and abandoned playback otherwise stay pinned in every user's + "Continue Watching" row forever. Clearing a resume point only resets the + playback position — nothing is deleted and the item stays unwatched. +
+
+ +
Runs daily at 4 AM (Scheduled Tasks → "Clean Up SRF Play Continue Watching")
+
+
+ +
Clears the position as soon as a livestream stops playing, rather than waiting for the daily task
+
+
+ + +
Clear resume points not touched for this many days. 0 disables the rule. Default 30.
+
+
+ + +
Positions at or below this are treated as an accidental start and cleared. 0 disables the rule. Default 60.
+
+
+ + +
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.
+
+

Network Settings

@@ -105,6 +141,17 @@
+
+

Continue Watching Maintenance

+
Run the cleanup right now instead of waiting for the daily task. Only SRF Play items are affected.
+
+ + +

Sport Livestream Recordings

@@ -154,6 +201,11 @@ 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 @@ -180,6 +232,11 @@ 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); }); @@ -189,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 = '

Cleaning up...

'; + + 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 = '

Cleared ' + result.cleared + ' of ' + result.inspected + + ' SRF Play resume point(s) across ' + result.usersChecked + ' user(s).' + + (reasons ? ' (' + reasons + ')' : '') + '

'; + }) + .catch(function(error) { + target.innerHTML = '

Cleanup failed: ' + error.message + '

'; + }); + } + }; + var SRFPlayRecordings = { apiBase: ApiClient.serverAddress() + '/Plugins/SRFPlay/Recording', diff --git a/Jellyfin.Plugin.SRFPlay/Controllers/MaintenanceController.cs b/Jellyfin.Plugin.SRFPlay/Controllers/MaintenanceController.cs new file mode 100644 index 0000000..abade96 --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Controllers/MaintenanceController.cs @@ -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; + +/// +/// Administrative maintenance actions for the SRF Play plugin. +/// +[ApiController] +[Route("Plugins/SRFPlay/Maintenance")] +[Authorize(Policy = Policies.RequiresElevation)] +public class MaintenanceController : ControllerBase +{ + private readonly ILogger _logger; + private readonly IResumeCleanupService _resumeCleanupService; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The resume cleanup service. + public MaintenanceController( + ILogger logger, + IResumeCleanupService resumeCleanupService) + { + _logger = logger; + _resumeCleanupService = resumeCleanupService; + } + + /// + /// Clears stale SRF Play resume points from every user's "Continue Watching" row. + /// + /// + /// When true, clears every SRF Play resume point instead of only the stale ones. + /// + /// The cancellation token. + /// A summary of what was inspected and cleared. + [HttpPost("ResumePoints/Cleanup")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult CleanupResumePoints( + [FromQuery] bool clearAll, + CancellationToken cancellationToken) + { + _logger.LogInformation("Manual Continue Watching cleanup requested (clearAll: {ClearAll})", clearAll); + return Ok(_resumeCleanupService.Cleanup(clearAll, cancellationToken)); + } +} diff --git a/Jellyfin.Plugin.SRFPlay/ScheduledTasks/ResumeCleanupTask.cs b/Jellyfin.Plugin.SRFPlay/ScheduledTasks/ResumeCleanupTask.cs new file mode 100644 index 0000000..7046970 --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/ScheduledTasks/ResumeCleanupTask.cs @@ -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; + +/// +/// Scheduled task that clears stale SRF Play resume points from "Continue Watching". +/// +public class ResumeCleanupTask : IScheduledTask +{ + private readonly ILogger _logger; + private readonly IResumeCleanupService _resumeCleanupService; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The resume cleanup service. + public ResumeCleanupTask( + ILogger logger, + IResumeCleanupService resumeCleanupService) + { + _logger = logger; + _resumeCleanupService = resumeCleanupService; + } + + /// + public string Name => "Clean Up SRF Play Continue Watching"; + + /// + public string Description => "Removes stale SRF Play resume points - ended livestreams, abandoned playback and finished programmes - from every user's Continue Watching row"; + + /// + public string Category => "SRF Play"; + + /// + public string Key => "SRFPlayResumeCleanup"; + + /// + public Task ExecuteAsync(IProgress 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; + } + + /// + public IEnumerable 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 + } + }; + } +} diff --git a/Jellyfin.Plugin.SRFPlay/ServiceRegistrator.cs b/Jellyfin.Plugin.SRFPlay/ServiceRegistrator.cs index d862cb8..cde880f 100644 --- a/Jellyfin.Plugin.SRFPlay/ServiceRegistrator.cs +++ b/Jellyfin.Plugin.SRFPlay/ServiceRegistrator.cs @@ -32,6 +32,11 @@ public class ServiceRegistrator : IPluginServiceRegistrator serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + + // Clears livestream resume points the moment playback stops, so ended broadcasts never + // linger in "Continue Watching" waiting for the nightly cleanup task. + serviceCollection.AddHostedService(); // Register metadata providers serviceCollection.AddSingleton(); @@ -48,6 +53,7 @@ public class ServiceRegistrator : IPluginServiceRegistrator serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); // 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 diff --git a/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IResumeCleanupService.cs b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IResumeCleanupService.cs new file mode 100644 index 0000000..4560e9e --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IResumeCleanupService.cs @@ -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; + +/// +/// Removes stale plugin-owned resume points so ended livestreams and abandoned playback +/// stop accumulating in every user's "Continue Watching" row. +/// +public interface IResumeCleanupService +{ + /// + /// 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. + /// + /// + /// When true every plugin-owned resume point is cleared regardless of the staleness rules. + /// Used by the "Clear all" maintenance action. + /// + /// The cancellation token. + /// A summary of what was inspected and cleared. + ResumeCleanupResult Cleanup(bool clearAll, CancellationToken cancellationToken); + + /// + /// Clears the resume point of a single item for the given users. + /// + /// The item whose resume point should be reset. + /// The users to clear it for. + /// A short reason, logged for traceability. + /// The number of resume points actually cleared. + int ClearResumePoint(BaseItem item, IEnumerable users, string reason); + + /// + /// Determines whether an item belongs to this plugin (an SRG channel item or a recording). + /// + /// The item to test. + /// True if the item is owned by this plugin. + bool IsPluginItem(BaseItem item); + + /// + /// 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. + /// + /// The item that just stopped playing. + /// The users the playback was attributed to. + /// The number of resume points cleared. + int ClearLiveStreamResumePoint(BaseItem item, IEnumerable users); +} diff --git a/Jellyfin.Plugin.SRFPlay/Services/MediaSourceFactory.cs b/Jellyfin.Plugin.SRFPlay/Services/MediaSourceFactory.cs index b45bc1a..caf9098 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/MediaSourceFactory.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/MediaSourceFactory.cs @@ -7,6 +7,7 @@ using Jellyfin.Plugin.SRFPlay.Api.Models; using Jellyfin.Plugin.SRFPlay.Configuration; using Jellyfin.Plugin.SRFPlay.Constants; using Jellyfin.Plugin.SRFPlay.Services.Interfaces; +using Jellyfin.Plugin.SRFPlay.Utilities; using MediaBrowser.Controller; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; @@ -66,7 +67,7 @@ public class MediaSourceFactory : IMediaSourceFactory // Past replays (ValidTo in the past) and upcoming events (ValidFrom in the future) // are treated as VOD to avoid IsInfiniteStream/IgnoreDts flags and FFmpeg -re mode. var isScheduledLivestream = chapter.Type == "SCHEDULED_LIVESTREAM" || - urn.Contains("livestream", StringComparison.OrdinalIgnoreCase); + UrnHelper.IsLivestreamUrn(urn); var now = DateTime.UtcNow; var isLiveStream = isScheduledLivestream && (chapter.ValidFrom == null || chapter.ValidFrom.Value.ToUniversalTime() <= now) && diff --git a/Jellyfin.Plugin.SRFPlay/Services/PlaybackResumeGuard.cs b/Jellyfin.Plugin.SRFPlay/Services/PlaybackResumeGuard.cs new file mode 100644 index 0000000..65cd45e --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Services/PlaybackResumeGuard.cs @@ -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; + +/// +/// 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. +/// +public class PlaybackResumeGuard : IHostedService +{ + private readonly ILogger _logger; + private readonly ISessionManager _sessionManager; + private readonly IResumeCleanupService _resumeCleanupService; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The session manager raising playback events. + /// The resume cleanup service. + public PlaybackResumeGuard( + ILogger logger, + ISessionManager sessionManager, + IResumeCleanupService resumeCleanupService) + { + _logger = logger; + _sessionManager = sessionManager; + _resumeCleanupService = resumeCleanupService; + } + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _sessionManager.PlaybackStopped += OnPlaybackStopped; + _logger.LogDebug("PlaybackResumeGuard attached to session manager"); + return Task.CompletedTask; + } + + /// + 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); + } + } +} diff --git a/Jellyfin.Plugin.SRFPlay/Services/RecordingService.cs b/Jellyfin.Plugin.SRFPlay/Services/RecordingService.cs index b3e3289..c07427a 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/RecordingService.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/RecordingService.cs @@ -13,6 +13,7 @@ using Jellyfin.Plugin.SRFPlay.Api; using Jellyfin.Plugin.SRFPlay.Api.Models; using Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3; using Jellyfin.Plugin.SRFPlay.Services.Interfaces; +using Jellyfin.Plugin.SRFPlay.Utilities; using MediaBrowser.Controller; using MediaBrowser.Controller.MediaEncoding; using Microsoft.Extensions.Logging; @@ -442,7 +443,7 @@ public class RecordingService : IRecordingService, IDisposable // Register the stream with the proxy so we can use the proxy URL var itemId = $"rec_{entry.Id}"; - var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" || entry.Urn.Contains("livestream", StringComparison.OrdinalIgnoreCase); + var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" || UrnHelper.IsLivestreamUrn(entry.Urn); _proxyService.RegisterStreamDeferred(itemId, streamUrl, entry.Urn, isLiveStream); // Build proxy URL for ffmpeg (use localhost for local access) diff --git a/Jellyfin.Plugin.SRFPlay/Services/ResumeCleanupService.cs b/Jellyfin.Plugin.SRFPlay/Services/ResumeCleanupService.cs new file mode 100644 index 0000000..104fbbb --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Services/ResumeCleanupService.cs @@ -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; + +/// +/// Removes stale plugin-owned resume points from every user's "Continue Watching" row. +/// +public class ResumeCleanupService : IResumeCleanupService +{ + /// + /// Provider ID key stamped onto every item built from the SRG API. + /// + private const string SrfProviderId = "SRF"; + + private readonly ILogger _logger; + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IUserDataManager _userDataManager; + + /// + /// Initializes a new instance of the class. + /// + /// The logger factory. + /// The library manager. + /// The user manager. + /// The user data manager. + public ResumeCleanupService( + ILoggerFactory loggerFactory, + ILibraryManager libraryManager, + IUserManager userManager, + IUserDataManager userDataManager) + { + _logger = loggerFactory.CreateLogger(); + _libraryManager = libraryManager; + _userManager = userManager; + _userDataManager = userDataManager; + } + + /// + 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; + } + + /// + public int ClearResumePoint(BaseItem item, IEnumerable 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; + } + + /// + public bool IsPluginItem(BaseItem item) + { + return IsPluginItem(item, GetPluginChannelIds()); + } + + /// + public int ClearLiveStreamResumePoint(BaseItem item, IEnumerable 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"); + } + + /// + /// Determines whether an item belongs to one of this plugin's channels. + /// + /// The item to test. + /// The set of channel IDs owned by this plugin. + /// True if the item is owned by this plugin. + private static bool IsPluginItem(BaseItem item, HashSet 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)); + } + + /// + /// Resolves the IDs of the channel entries created by this plugin, one per business unit. + /// + /// The set of channel IDs, empty if the channels have not been scanned yet. + private HashSet GetPluginChannelIds() + { + // SrgChannelBase names every channel "{unit} Play". + var names = Enum.GetNames() + .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(); + } + + /// + /// Decides whether a resume point is stale, and why. + /// + /// The item the resume point belongs to. + /// The user data holding the resume position. + /// The plugin configuration supplying the thresholds. + /// A short reason string, or null when the resume point should be kept. + 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; + } + + /// + /// 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. + /// + /// The item to clear. + /// The user to clear it for. + /// The user data to update. + /// A short reason, logged for traceability. + /// True if the resume point was cleared. + 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; + } + } +} diff --git a/Jellyfin.Plugin.SRFPlay/Utilities/UrnHelper.cs b/Jellyfin.Plugin.SRFPlay/Utilities/UrnHelper.cs index 44d6728..f4ac967 100644 --- a/Jellyfin.Plugin.SRFPlay/Utilities/UrnHelper.cs +++ b/Jellyfin.Plugin.SRFPlay/Utilities/UrnHelper.cs @@ -24,4 +24,17 @@ public static class UrnHelper return guid.ToString(); } #pragma warning restore CA5351 + + /// + /// 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. + /// + /// The URN to inspect. May be null or empty. + /// True if the URN identifies livestream content. + public static bool IsLivestreamUrn(string? urn) + { + return !string.IsNullOrEmpty(urn) + && urn.Contains("livestream", StringComparison.OrdinalIgnoreCase); + } } diff --git a/README.md b/README.md index c8debbc..6eb07e2 100644 --- a/README.md +++ b/README.md @@ -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).