Skip to content

Commit

Permalink
release(patch): Version 4.1.1
Browse files Browse the repository at this point in the history
So turns out the inconsistent id issues on the series was why watch data were being stored differently depending on how you refreshed your library/series last. It was also the reason why series merging was flaky for non-VFS managed libraries, and part of the reason why the "Next Up" and "Continue Watching" sections on the dashboards were not functioning as expected for Shoko managed libraries.

To remedy the aftereffects I'll recommend updating and running a refresh/scan with "Add Missing Metadata" on all your Shoko managed libraries, waiting for them to complete, followed by running the new "Migrate Episode User Watch Data" scheduled task afterwards, as these steps should fix the watch data and series merging issues.

 ## Changes since last release

Here are the main changes since the last stable release (4.1.0):

---

`fix`: **Add catch in try get method**:

- Don't throw in the try get method for retrieving the file id.

`fix`: **Add episode user data migration task**.

`fix`: **Improve logic in the episode user data migration task**.

For the full list of changes, please check out the [complete changelog](4.1.0...4.1.1) here on GitHub.
  • Loading branch information
revam committed Jul 16, 2024
2 parents 1b88da3 + 4679c51 commit 53a34cb
Show file tree
Hide file tree
Showing 3 changed files with 240 additions and 4 deletions.
13 changes: 9 additions & 4 deletions Shokofin/API/ShokoAPIManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
62 changes: 62 additions & 0 deletions Shokofin/Sync/SyncExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
169 changes: 169 additions & 0 deletions Shokofin/Tasks/MigrateEpisodeUserDataTask.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <inheritdoc />
public string Name => "Migrate Episode User Watch Data";

/// <inheritdoc />
public string Description => "Migrate user watch data for episodes store in Jellyfin to the newest id namespace.";

/// <inheritdoc />
public string Category => "Shokofin";

/// <inheritdoc />
public string Key => "ShokoMigrateEpisodeUserDataTask";

/// <inheritdoc />
public bool IsHidden => false;

/// <inheritdoc />
public bool IsEnabled => false;

/// <inheritdoc />
public bool IsLogged => true;

private readonly ILogger<MigrateEpisodeUserDataTask> Logger;

private readonly IUserDataManager UserDataManager;

private readonly IUserManager UserManager;

private readonly ILibraryManager LibraryManager;

public MigrateEpisodeUserDataTask(
ILogger<MigrateEpisodeUserDataTask> logger,
IUserDataManager userDataManager,
IUserManager userManager,
ILibraryManager libraryManager
)
{
Logger = logger;
UserDataManager = userDataManager;
UserManager = userManager;
LibraryManager = libraryManager;
}

public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
=> Array.Empty<TaskTriggerInfo>();

public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
var foundEpisodeCount = 0;
var seriesDict = new Dictionary<string, (Series series, List<Episode> episodes)>();
var users = UserManager.Users.ToList();
var allEpisodes = LibraryManager.GetItemList(new InternalItemsQuery {
IncludeItemTypes = [BaseItemKind.Episode],
HasAnyProviderId = new Dictionary<string, string> { { ShokoFileId.Name, string.Empty } },
IsFolder = false,
Recursive = true,
DtoOptions = new(false) {
EnableImages = false
},
SourceTypes = [SourceType.Library],
IsVirtualItem = false,
})
.OfType<Episode>()
.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<UserItemData>()));
var userDataToRemove = new List<UserItemData>();
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;
}
}

0 comments on commit 53a34cb

Please sign in to comment.