From 560c17bdf458543447b397869e439068bf11b17d Mon Sep 17 00:00:00 2001 From: h3llrais3r Date: Mon, 28 Nov 2022 19:42:33 +0100 Subject: [PATCH 1/3] Fix episode matching for sync from trakt task - Trakt has switched from tvdb to tmdb as indexer - Episodes are often not aligned between tvdb and tmdb - Watched shows api does not contain episodes ids - History api does contain episode ids - If no matching is found by season and number (from shows api), fallback to history api to determine a match by episode ids --- .../History/TraktEpisodeWatchedHistory.cs | 40 ++++++++++ .../Sync/History/TraktMovieWatchedHistory.cs | 34 +++++++++ Trakt/Api/TraktApi.cs | 74 +++++++++++++++++++ Trakt/Api/TraktURIs.cs | 10 +++ Trakt/Extensions.cs | 23 ++++++ Trakt/ScheduledTasks/SyncFromTraktTask.cs | 34 ++++++++- 6 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 Trakt/Api/DataContracts/Sync/History/TraktEpisodeWatchedHistory.cs create mode 100644 Trakt/Api/DataContracts/Sync/History/TraktMovieWatchedHistory.cs diff --git a/Trakt/Api/DataContracts/Sync/History/TraktEpisodeWatchedHistory.cs b/Trakt/Api/DataContracts/Sync/History/TraktEpisodeWatchedHistory.cs new file mode 100644 index 0000000..826f35b --- /dev/null +++ b/Trakt/Api/DataContracts/Sync/History/TraktEpisodeWatchedHistory.cs @@ -0,0 +1,40 @@ +using System.Text.Json.Serialization; +using Trakt.Api.DataContracts.BaseModel; + +namespace Trakt.Api.DataContracts.Sync.History; + +/// +/// The trakt.tv sync episode watched history class. +/// +public class TraktEpisodeWatchedHistory +{ + /// + /// Gets or sets the watched date. + /// + [JsonPropertyName("watched_at")] + public string WatchedAt { get; set; } + + /// + /// Gets or sets the action. + /// + [JsonPropertyName("action")] + public string Action { get; set; } + + /// + /// Gets or sets the type. + /// + [JsonPropertyName("type")] + public string Type { get; set; } + + /// + /// Gets or sets the episode. + /// + [JsonPropertyName("episode")] + public TraktEpisode Episode { get; set; } + + /// + /// Gets or sets the episode. + /// + [JsonPropertyName("show")] + public TraktShow Show { get; set; } +} diff --git a/Trakt/Api/DataContracts/Sync/History/TraktMovieWatchedHistory.cs b/Trakt/Api/DataContracts/Sync/History/TraktMovieWatchedHistory.cs new file mode 100644 index 0000000..386d81e --- /dev/null +++ b/Trakt/Api/DataContracts/Sync/History/TraktMovieWatchedHistory.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; +using Trakt.Api.DataContracts.BaseModel; + +namespace Trakt.Api.DataContracts.Sync.History; + +/// +/// The trakt.tv sync movie watched history class. +/// +public class TraktMovieWatchedHistory +{ + /// + /// Gets or sets the watched date. + /// + [JsonPropertyName("watched_at")] + public string WatchedAt { get; set; } + + /// + /// Gets or sets the action. + /// + [JsonPropertyName("action")] + public string Action { get; set; } + + /// + /// Gets or sets the type. + /// + [JsonPropertyName("type")] + public string Type { get; set; } + + /// + /// Gets or sets the movie. + /// + [JsonPropertyName("movie")] + public TraktMovie Movie { get; set; } +} diff --git a/Trakt/Api/TraktApi.cs b/Trakt/Api/TraktApi.cs index 1f1fd11..759dbd0 100644 --- a/Trakt/Api/TraktApi.cs +++ b/Trakt/Api/TraktApi.cs @@ -647,6 +647,26 @@ public async Task> SendGetWat return await GetFromTrakt>(TraktUris.WatchedShows, traktUser).ConfigureAwait(false); } + /// + /// Get watched movies history. + /// + /// The . + /// Task{List{DataContracts.Sync.History.TraktMovieWatchedHistory}}. + public async Task> SendGetWatchedMoviesHistoryRequest(TraktUser traktUser) + { + return await GetFromTraktWithPaging(TraktUris.SyncWatchedMoviesHistory, traktUser).ConfigureAwait(false); + } + + /// + /// Get watched episodes history. + /// + /// The . + /// Task{List{DataContracts.Sync.History.TraktEpisodeWatchedHistory}}. + public async Task> SendGetWatchedEpisodesHistoryRequest(TraktUser traktUser) + { + return await GetFromTraktWithPaging(TraktUris.SyncWatchedEpisodesHistory, traktUser).ConfigureAwait(false); + } + /// /// Get all paused movies. /// @@ -1080,6 +1100,60 @@ private async Task GetFromTrakt(string url, TraktUser traktUser, Cancellat } } + private Task> GetFromTraktWithPaging(string url, TraktUser traktUser) + { + return GetFromTraktWithPaging(url, traktUser, CancellationToken.None); + } + + private async Task> GetFromTraktWithPaging(string url, TraktUser traktUser, CancellationToken cancellationToken) + { + var httpClient = GetHttpClient(); + var page = 1; + var result = new List(); + + if (traktUser != null) + { + await SetRequestHeaders(httpClient, traktUser).ConfigureAwait(false); + } + + await _traktResourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + while (true) + { + var urlWithPage = url.Replace("{page}", page.ToString(CultureInfo.InvariantCulture), StringComparison.InvariantCulture); + var response = await RetryHttpRequest(async () => await httpClient.GetAsync(urlWithPage, cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.NotFound) + { + return result; + } + + response.EnsureSuccessStatusCode(); + var tmpResult = await response.Content.ReadFromJsonAsync>(_jsonOptions, cancellationToken).ConfigureAwait(false); + if (result.GetType().IsGenericType && result.GetType().GetGenericTypeDefinition() == typeof(List<>)) + { + result.AddRange(tmpResult); + } + + if (int.Parse(response.Headers.GetValues("X-Pagination-Page-Count").FirstOrDefault(page.ToString(CultureInfo.InvariantCulture)), CultureInfo.InvariantCulture) != page) + { + page++; + } + else + { + break; // break loop when no more new pages are available + } + } + + return result; + } + finally + { + _traktResourcePool.Release(); + } + } + private async Task PostToTrakt(string url, object data) { var httpClient = GetHttpClient(); diff --git a/Trakt/Api/TraktURIs.cs b/Trakt/Api/TraktURIs.cs index ac94fc9..db2e491 100644 --- a/Trakt/Api/TraktURIs.cs +++ b/Trakt/Api/TraktURIs.cs @@ -50,6 +50,16 @@ public static class TraktUris /// public const string SyncCollectionRemove = BaseUrl + "/sync/collection/remove"; + /// + /// The watched movies history URI. + /// + public const string SyncWatchedMoviesHistory = BaseUrl + "/sync/history/movies?page={page}&limit=1000"; + + /// + /// The watched episodes history URI. + /// + public const string SyncWatchedEpisodesHistory = BaseUrl + "/sync/history/episodes?page={page}&limit=1000"; + /// /// The watched history add URI. /// diff --git a/Trakt/Extensions.cs b/Trakt/Extensions.cs index 5acf1da..e884ad6 100644 --- a/Trakt/Extensions.cs +++ b/Trakt/Extensions.cs @@ -7,6 +7,7 @@ using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Model.Entities; using Trakt.Api.DataContracts.BaseModel; +using Trakt.Api.DataContracts.Sync.History; using Trakt.Api.DataContracts.Users.Collection; using Trakt.Api.DataContracts.Users.Playback; using Trakt.Api.DataContracts.Users.Watched; @@ -424,6 +425,28 @@ public static TraktMoviePaused FindMatch(BaseItem item, IEnumerable IsMatch(item, i.Movie)); } + /// + /// Gets a watched history match for a movie. + /// + /// The . + /// >The . + /// TraktMovieWatchedHistory. + public static TraktMovieWatchedHistory FindMatch(Movie item, IEnumerable results) + { + return results.FirstOrDefault(i => IsMatch(item, i.Movie)); + } + + /// + /// Gets a watched history match for an episode. + /// + /// The . + /// >The . + /// TraktMovieWatchedHistory. + public static TraktEpisodeWatchedHistory FindMatch(Episode item, IEnumerable results) + { + return results.FirstOrDefault(i => IsMatch(item, i.Episode)); + } + /// /// Checks if a matches a . /// diff --git a/Trakt/ScheduledTasks/SyncFromTraktTask.cs b/Trakt/ScheduledTasks/SyncFromTraktTask.cs index 23e011b..f792190 100644 --- a/Trakt/ScheduledTasks/SyncFromTraktTask.cs +++ b/Trakt/ScheduledTasks/SyncFromTraktTask.cs @@ -16,6 +16,7 @@ using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; using Trakt.Api; +using Trakt.Api.DataContracts.Sync.History; using Trakt.Api.DataContracts.Users.Playback; using Trakt.Api.DataContracts.Users.Watched; using Trakt.Helpers; @@ -122,6 +123,8 @@ private async Task SyncTraktDataForUser(Jellyfin.Data.Entities.User user, double List traktWatchedMovies = new List(); List traktWatchedShows = new List(); + List traktWatchedMoviesHistory = new List(); // not used for now, just for reference to get watched movies history count + List traktWatchedEpisodesHistory = new List(); // used for fall episode matching by ids List traktPausedMovies = new List(); List traktPausedEpisodes = new List(); @@ -136,6 +139,8 @@ private async Task SyncTraktDataForUser(Jellyfin.Data.Entities.User user, double { traktWatchedMovies.AddRange(await _traktApi.SendGetAllWatchedMoviesRequest(traktUser).ConfigureAwait(false)); traktWatchedShows.AddRange(await _traktApi.SendGetWatchedShowsRequest(traktUser).ConfigureAwait(false)); + traktWatchedMoviesHistory.AddRange(await _traktApi.SendGetWatchedMoviesHistoryRequest(traktUser).ConfigureAwait(false)); + traktWatchedEpisodesHistory.AddRange(await _traktApi.SendGetWatchedEpisodesHistoryRequest(traktUser).ConfigureAwait(false)); } if (!traktUser.SkipPlaybackProgressImportFromTrakt) @@ -151,8 +156,10 @@ private async Task SyncTraktDataForUser(Jellyfin.Data.Entities.User user, double } _logger.LogInformation("Trakt.tv watched movies for user {User}: {Count}", user.Username, traktWatchedMovies.Count); + _logger.LogInformation("Trakt.tv watched movies history for user {User}: {Count}", user.Username, traktWatchedMoviesHistory.Count); _logger.LogInformation("Trakt.tv paused movies for user {User}: {Count}", user.Username, traktPausedMovies.Count); _logger.LogInformation("Trakt.tv watched shows for user {User}: {Count}", user.Username, traktWatchedShows.Count); + _logger.LogInformation("Trakt.tv watched episodes history for user {User}: {Count}", user.Username, traktWatchedEpisodesHistory.Count); _logger.LogInformation("Trakt.tv paused episodes for user {User}: {Count}", user.Username, traktPausedEpisodes.Count); var baseQuery = new InternalItemsQuery(user) @@ -312,6 +319,7 @@ private async Task SyncTraktDataForUser(Jellyfin.Data.Entities.User user, double { cancellationToken.ThrowIfCancellationRequested(); var matchedWatchedShow = Extensions.FindMatch(episode.Series, traktWatchedShows); + var matchedWatchedEpisodeHistory = Extensions.FindMatch(episode, traktWatchedEpisodesHistory); var matchedPausedEpisode = Extensions.FindMatch(episode, traktPausedEpisodes); var userData = _userDataManager.GetUserData(user.Id, episode); bool changed = false; @@ -328,12 +336,28 @@ private async Task SyncTraktDataForUser(Jellyfin.Data.Entities.User user, double tLastReset = resetValue; } + // Fallback procedure to find match by using episode history + if (matchedWatchedSeason == null && matchedWatchedEpisodeHistory != null) + { + // Find watched season via history match + _logger.LogDebug("Using history to match season for user {User} for {Data}", user.Username, GetVerboseEpisodeData(episode)); + matchedWatchedSeason = matchedWatchedShow.Seasons.FirstOrDefault(tSeason => tSeason.Number == matchedWatchedEpisodeHistory.Episode.Season); + } + // If it's not a match then it means trakt.tv doesn't know about the season, leave the watched state alone and move on if (matchedWatchedSeason != null) { // Check for matching episodes including multi-episode entities var matchedWatchedEpisode = matchedWatchedSeason.Episodes.FirstOrDefault(x => episode.ContainsEpisodeNumber(x.Number)); + // Fallback procedure to find match by using episode history + if (matchedWatchedEpisode == null && matchedWatchedEpisodeHistory != null) + { + // Find watched season via history match + _logger.LogDebug("Using history to match episode for user {User} for {Data}", user.Username, GetVerboseEpisodeData(episode)); + matchedWatchedEpisode = matchedWatchedSeason.Episodes.FirstOrDefault(tEpisode => tEpisode.Number == matchedWatchedEpisodeHistory.Episode.Number); + } + // Prepend a check if the matched episode is on a rewatch cycle and // discard it if the last play date was before the reset date if (matchedWatchedEpisode != null @@ -467,7 +491,15 @@ private static string GetVerboseEpisodeData(Episode episode) ? episode.Series.Name : "null property" : "null class") - .Append('\''); + .Append("' ") + .Append("Tvdb id: ") + .Append(episode.GetProviderId(MetadataProvider.Tvdb) ?? "null").Append(' ') + .Append("Tmdb id: ") + .Append(episode.GetProviderId(MetadataProvider.Tmdb) ?? "null").Append(' ') + .Append("Imdb id: ") + .Append(episode.GetProviderId(MetadataProvider.Imdb) ?? "null").Append(' ') + .Append("TvRage id: ") + .Append(episode.GetProviderId(MetadataProvider.TvRage) ?? "null"); return episodeString.ToString(); } From 7c6e0027aaf219810d8f4b79890d7afe55b2e201 Mon Sep 17 00:00:00 2001 From: h3llrais3r Date: Mon, 28 Nov 2022 21:43:51 +0100 Subject: [PATCH 2/3] Add fallback to match episode when it belongs to another season When an episode is not matched with the season, let's find it in any season. This happens when an episode of the next season in tvdb appears in the previous season in tmdb. (F.e. Hunter X Hunter S02E01 tvdb = S01E59 tmdb) --- Trakt/ScheduledTasks/SyncFromTraktTask.cs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/Trakt/ScheduledTasks/SyncFromTraktTask.cs b/Trakt/ScheduledTasks/SyncFromTraktTask.cs index f792190..f5f3a7b 100644 --- a/Trakt/ScheduledTasks/SyncFromTraktTask.cs +++ b/Trakt/ScheduledTasks/SyncFromTraktTask.cs @@ -353,11 +353,26 @@ private async Task SyncTraktDataForUser(Jellyfin.Data.Entities.User user, double // Fallback procedure to find match by using episode history if (matchedWatchedEpisode == null && matchedWatchedEpisodeHistory != null) { - // Find watched season via history match - _logger.LogDebug("Using history to match episode for user {User} for {Data}", user.Username, GetVerboseEpisodeData(episode)); + // Find watched season and episode via history match + _logger.LogDebug("Using history to match season and episode for user {User} for {Data}", user.Username, GetVerboseEpisodeData(episode)); matchedWatchedEpisode = matchedWatchedSeason.Episodes.FirstOrDefault(tEpisode => tEpisode.Number == matchedWatchedEpisodeHistory.Episode.Number); } + // Fallback procedure to find match by using episode history, without checking the season (episode can belong to different season) + if (matchedWatchedEpisode == null && matchedWatchedEpisodeHistory != null) + { + // Find watched episode via history match + _logger.LogDebug("Using history to match episode for user {User} for {Data}", user.Username, GetVerboseEpisodeData(episode)); + foreach (var season in matchedWatchedShow.Seasons) + { + matchedWatchedEpisode = season.Episodes.FirstOrDefault(tEpisode => tEpisode.Number == matchedWatchedEpisodeHistory.Episode.Number); + if (matchedWatchedEpisode != null) + { + break; // stop when found in a season + } + } + } + // Prepend a check if the matched episode is on a rewatch cycle and // discard it if the last play date was before the reset date if (matchedWatchedEpisode != null From 7c013522c8156bc92588d36f41bf3696d50f608e Mon Sep 17 00:00:00 2001 From: h3llrais3r Date: Sun, 4 Dec 2022 18:48:37 +0100 Subject: [PATCH 3/3] Replace type check by null check --- Trakt/Api/TraktApi.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Trakt/Api/TraktApi.cs b/Trakt/Api/TraktApi.cs index 759dbd0..2853f49 100644 --- a/Trakt/Api/TraktApi.cs +++ b/Trakt/Api/TraktApi.cs @@ -1131,7 +1131,7 @@ private async Task> GetFromTraktWithPaging(string url, TraktUser trak response.EnsureSuccessStatusCode(); var tmpResult = await response.Content.ReadFromJsonAsync>(_jsonOptions, cancellationToken).ConfigureAwait(false); - if (result.GetType().IsGenericType && result.GetType().GetGenericTypeDefinition() == typeof(List<>)) + if (tmpResult != null) { result.AddRange(tmpResult); }