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).