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; } } }