diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 70742a36..f8cf95be 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -562,10 +562,15 @@ public bool TryGetFileIdForPath(string path, [NotNullWhen(true)] out string? fil // Slow path; getting the show from cache or remote and finding the default season's id. Logger.LogDebug("Trying to find file id using the slow path. (Path={FullPath})", path); - if (GetFileInfoByPath(path).ConfigureAwait(false).GetAwaiter().GetResult() is { } tuple && tuple.Item1 is not null) { - var (fileInfo, _, _) = tuple; - fileId = fileInfo.Id; - return true; + try { + if (GetFileInfoByPath(path).ConfigureAwait(false).GetAwaiter().GetResult() is { } tuple && tuple.Item1 is not null) { + var (fileInfo, _, _) = tuple; + fileId = fileInfo.Id; + return true; + } + } + catch (Exception ex) { + Logger.LogError(ex, "Encountered an error while trying to lookup the file id for {Path}", path); } fileId = null; diff --git a/Shokofin/Sync/SyncExtensions.cs b/Shokofin/Sync/SyncExtensions.cs index 936ea01d..81777bed 100644 --- a/Shokofin/Sync/SyncExtensions.cs +++ b/Shokofin/Sync/SyncExtensions.cs @@ -22,6 +22,68 @@ public static File.UserStats ToFileUserStats(this UserItemData userData) }; } + public static bool CopyFrom(this UserItemData userData, UserItemData otherUserData) + { + var updated = false; + + if (!userData.Rating.HasValue && otherUserData.Rating.HasValue || userData.Rating.HasValue && otherUserData.Rating.HasValue && userData.Rating != otherUserData.Rating) + { + userData.Rating = otherUserData.Rating; + updated = true; + } + + if (userData.PlaybackPositionTicks != otherUserData.PlaybackPositionTicks) + { + userData.PlaybackPositionTicks = otherUserData.PlaybackPositionTicks; + updated = true; + } + + if (userData.PlayCount != otherUserData.PlayCount) + { + userData.PlayCount = otherUserData.PlayCount; + updated = true; + } + + if (!userData.IsFavorite != otherUserData.IsFavorite) + { + userData.IsFavorite = otherUserData.IsFavorite; + updated = true; + } + + if (!userData.LastPlayedDate.HasValue && otherUserData.LastPlayedDate.HasValue || userData.LastPlayedDate.HasValue && otherUserData.LastPlayedDate.HasValue && userData.LastPlayedDate < otherUserData.LastPlayedDate) + { + userData.LastPlayedDate = otherUserData.LastPlayedDate; + updated = true; + } + + if (userData.Played != otherUserData.Played) + { + userData.Played = otherUserData.Played; + updated = true; + } + + if (!userData.AudioStreamIndex.HasValue && otherUserData.AudioStreamIndex.HasValue || userData.AudioStreamIndex.HasValue && otherUserData.AudioStreamIndex.HasValue && userData.AudioStreamIndex != otherUserData.AudioStreamIndex) + { + userData.AudioStreamIndex = otherUserData.AudioStreamIndex; + updated = true; + } + + if (!userData.SubtitleStreamIndex.HasValue && otherUserData.SubtitleStreamIndex.HasValue || userData.SubtitleStreamIndex.HasValue && otherUserData.SubtitleStreamIndex.HasValue && userData.SubtitleStreamIndex != otherUserData.SubtitleStreamIndex) + { + userData.SubtitleStreamIndex = otherUserData.SubtitleStreamIndex; + updated = true; + } + + if (!userData.Likes.HasValue && otherUserData.Likes.HasValue || userData.Likes.HasValue && otherUserData.Likes.HasValue && userData.Likes != otherUserData.Likes) + { + userData.Likes = otherUserData.Likes; + updated = true; + } + + return updated; + } + + public static UserItemData MergeWithFileUserStats(this UserItemData userData, File.UserStats userStats) { userData.Played = userStats.LastWatchedAt.HasValue; diff --git a/Shokofin/Tasks/MigrateEpisodeUserDataTask.cs b/Shokofin/Tasks/MigrateEpisodeUserDataTask.cs new file mode 100644 index 00000000..0fac08ab --- /dev/null +++ b/Shokofin/Tasks/MigrateEpisodeUserDataTask.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging; +using Shokofin.ExternalIds; +using Shokofin.Providers; +using Shokofin.Sync; + +namespace Shokofin.Tasks; + +public class MigrateEpisodeUserDataTask : IScheduledTask, IConfigurableScheduledTask +{ + /// + public string Name => "Migrate Episode User Watch Data"; + + /// + public string Description => "Migrate user watch data for episodes store in Jellyfin to the newest id namespace."; + + /// + public string Category => "Shokofin"; + + /// + public string Key => "ShokoMigrateEpisodeUserDataTask"; + + /// + public bool IsHidden => false; + + /// + public bool IsEnabled => false; + + /// + public bool IsLogged => true; + + private readonly ILogger Logger; + + private readonly IUserDataManager UserDataManager; + + private readonly IUserManager UserManager; + + private readonly ILibraryManager LibraryManager; + + public MigrateEpisodeUserDataTask( + ILogger logger, + IUserDataManager userDataManager, + IUserManager userManager, + ILibraryManager libraryManager + ) + { + Logger = logger; + UserDataManager = userDataManager; + UserManager = userManager; + LibraryManager = libraryManager; + } + + public IEnumerable GetDefaultTriggers() + => Array.Empty(); + + public Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) + { + var foundEpisodeCount = 0; + var seriesDict = new Dictionary episodes)>(); + var users = UserManager.Users.ToList(); + var allEpisodes = LibraryManager.GetItemList(new InternalItemsQuery { + IncludeItemTypes = [BaseItemKind.Episode], + HasAnyProviderId = new Dictionary { { ShokoFileId.Name, string.Empty } }, + IsFolder = false, + Recursive = true, + DtoOptions = new(false) { + EnableImages = false + }, + SourceTypes = [SourceType.Library], + IsVirtualItem = false, + }) + .OfType() + .ToList(); + Logger.LogDebug("Attempting to migrate user watch data across {EpisodeCount} episodes and {UserCount} users.", allEpisodes.Count, users.Count); + foreach (var episode in allEpisodes) { + cancellationToken.ThrowIfCancellationRequested(); + + if (!episode.ParentIndexNumber.HasValue || !episode.IndexNumber.HasValue || + !episode.TryGetProviderId(ShokoFileId.Name, out var fileId) || + episode.Series is not Series series || !series.TryGetProviderId(ShokoSeriesId.Name, out var seriesId)) + continue; + + if (!seriesDict.TryGetValue(seriesId, out var tuple)) + seriesDict[seriesId] = tuple = (series, []); + + tuple.episodes.Add(episode); + foundEpisodeCount++; + } + + Logger.LogInformation("Found {SeriesCount} series and {EpisodeCount} episodes across {AllEpisodeCount} total episodes to search for user watch data to migrate.", seriesDict.Count, foundEpisodeCount, allEpisodes.Count); + var savedCount = 0; + var numComplete = 0; + var numTotal = foundEpisodeCount * users.Count; + var userDataDict = users.ToDictionary(user => user, user => (UserDataManager.GetAllUserData(user.Id).DistinctBy(data => data.Key).ToDictionary(data => data.Key), new List())); + var userDataToRemove = new List(); + foreach (var (seriesId, (series, episodes)) in seriesDict) { + cancellationToken.ThrowIfCancellationRequested(); + + SeriesProvider.AddProviderIds(series, seriesId); + var seriesUserKeys = series.GetUserDataKeys(); + // 10.9 post-4.1 id format + var primaryKey = seriesUserKeys.First(); + var keysToSearch = seriesUserKeys.Skip(1) + // 10.9 pre-4.1 id format + .Prepend($"shoko://shoko-series={seriesId}") + // 10.8 id format + .Prepend($"INVALID-BUT-DO-NOT-TOUCH:{seriesId}") + .ToList(); + Logger.LogTrace("Migrating user watch data for series {SeriesName}. (Series={SeriesId},Primary={PrimaryKey},Search={SearchKeys})", series.Name, seriesId, primaryKey, keysToSearch); + foreach (var episode in episodes) { + cancellationToken.ThrowIfCancellationRequested(); + + if (!episode.TryGetProviderId(ShokoFileId.Name, out var fileId)) + continue; + + var suffix = episode.ParentIndexNumber!.Value.ToString("000", CultureInfo.InvariantCulture) + episode.IndexNumber!.Value.ToString("000", CultureInfo.InvariantCulture); + var videoUserDataKeys = (episode as Video).GetUserDataKeys(); + var episodeKeysToSearch = keysToSearch.Select(key => key + suffix).Prepend(primaryKey + suffix).Concat(videoUserDataKeys).ToList(); + Logger.LogTrace("Migrating user watch data for season {SeasonNumber}, episode {EpisodeNumber} - {EpisodeName}. (Series={SeriesId},File={FileId},Search={SearchKeys})", episode.ParentIndexNumber, episode.IndexNumber, episode.Name, seriesId, fileId, episodeKeysToSearch); + foreach (var (user, (dataDict, dataList)) in userDataDict) { + var userData = UserDataManager.GetUserData(user, episode); + foreach (var searchKey in episodeKeysToSearch) { + if (!dataDict.TryGetValue(searchKey, out var searchUserData)) + continue; + + if (userData.CopyFrom(searchUserData)) { + Logger.LogInformation("Found user data to migrate. (Series={SeriesId},File={FileId},Search={SearchKeys},Key={SearchKey},User={UserId})", seriesId, fileId, episodeKeysToSearch, searchKey, user.Id); + dataList.Add(userData); + savedCount++; + } + break; + } + + numComplete++; + double percent = numComplete; + percent /= numTotal; + + progress.Report(percent * 100); + } + } + } + + // Last attempt to cancel before we save all the changes. + cancellationToken.ThrowIfCancellationRequested(); + + Logger.LogDebug("Saving {UserDataCount} user watch data entries across {UserCount} users", savedCount, users.Count); + foreach (var (user, (dataDict, dataList)) in userDataDict) { + if (dataList.Count is 0) + continue; + + UserDataManager.SaveAllUserData(user.Id, dataList.ToArray(), CancellationToken.None); + } + Logger.LogInformation("Saved {UserDataCount} user watch data entries across {UserCount} users", savedCount, users.Count); + + progress.Report(100); + return Task.CompletedTask; + } +}