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.
';
+ });
+ }
+ };
+
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).