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