diff --git a/Directory.Build.props b/Directory.Build.props index c48b73e2..a07d1fa8 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,7 +5,7 @@ - 2.4.4 + 2.4.5 diff --git a/src/WebApi/ReferenceData/ReferenceDataAdminController.cs b/src/WebApi/ReferenceData/ReferenceDataAdminController.cs index 7787f619..23dde776 100644 --- a/src/WebApi/ReferenceData/ReferenceDataAdminController.cs +++ b/src/WebApi/ReferenceData/ReferenceDataAdminController.cs @@ -328,12 +328,9 @@ private async Task RunSyncJobAsync(Guid jobId, ReferenceSyncWindows windows, boo if (!exploreOnly) { var scopedSyncService = scope.ServiceProvider.GetRequiredService(); - var scopedReconciliationService = scope.ServiceProvider.GetRequiredService(); result = await scopedSyncService.SyncStaleReferencesAsync( windows.References, stage => scopedJobStore.UpdateStageAsync(jobId, stage), windows.FullFetch, cancellationToken); - // an on-demand "sync now" reconciles finished-show status too, so its result matches the periodic pass's. - result.FinishedShowsReopened = await scopedReconciliationService.ReconcileFinishedShowsAsync(); await scopedJobStore.UpdateStageAsync(jobId, ReferenceSyncStage.RefreshingExplore); } diff --git a/src/WebApi/ReferenceData/ReferenceSyncBackgroundService.cs b/src/WebApi/ReferenceData/ReferenceSyncBackgroundService.cs index a9b2d647..0b01ad6b 100644 --- a/src/WebApi/ReferenceData/ReferenceSyncBackgroundService.cs +++ b/src/WebApi/ReferenceData/ReferenceSyncBackgroundService.cs @@ -58,10 +58,6 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) var syncService = scope.ServiceProvider.GetRequiredService(); var result = await syncService.SyncStaleReferencesAsync(s_windows.References, fullFetch: s_windows.FullFetch, cancellationToken: stoppingToken); - // finished shows reconciled against the refreshed episode guides, in the same tick under the lease - var reconciliationService = scope.ServiceProvider.GetRequiredService(); - result.FinishedShowsReopened = await reconciliationService.ReconcileFinishedShowsAsync(stoppingToken); - // the Explore rankings ride the same tick on their own longer window (see ReferenceSyncWindows.Periodic) var exploreRefreshService = scope.ServiceProvider.GetRequiredService(); result.ApplyExploreRefresh(await exploreRefreshService.RefreshAsync(s_windows.Explore, stoppingToken)); diff --git a/src/WebApi/ReferenceData/ReferenceSyncService.cs b/src/WebApi/ReferenceData/ReferenceSyncService.cs index 6127eaf7..14c1c3f8 100644 --- a/src/WebApi/ReferenceData/ReferenceSyncService.cs +++ b/src/WebApi/ReferenceData/ReferenceSyncService.cs @@ -5,6 +5,10 @@ namespace Keeptrack.WebApi.ReferenceData; /// /// Keeps the shared reference collections up to date with their providers, since provider data (air dates, genres, covers, cast, ratings) changes after resolution. /// Shared by the periodic sync () and the admin's "sync now", so both run the same logic. +/// +/// Finished shows are reconciled right after the TV domain, since they depend on the TV guides alone, +/// and a forced pass over the other four domains takes long enough that a page polling for the result is gone before it ends. +/// /// public class ReferenceSyncService( ITvShowReferenceRepository tvShowReferenceRepository, @@ -13,6 +17,7 @@ public class ReferenceSyncService( IVideoGameReferenceRepository videoGameReferenceRepository, IAlbumReferenceRepository albumReferenceRepository, ReferenceEnrichmentService enrichmentService, + TvShowStatusReconciliationService tvShowStatusReconciliationService, ILogger logger) { /// @@ -39,6 +44,15 @@ public async Task SyncStaleReferencesAsync(TimeSpan stal await SyncDomainAsync(onStageChanged, ReferenceSyncStage.SyncingTvShows, cutoff, tvShowReferenceRepository.FindStaleAsync, (r, ct) => enrichmentService.RefreshTvShowReferenceAsync(r, fullFetch, ct), r => r.Id, (checkedCount, updated) => (result.TvShowsChecked, result.TvShowsUpdated) = (checkedCount, updated), cancellationToken); + try + { + result.FinishedShowsReopened = await tvShowStatusReconciliationService.ReconcileFinishedShowsAsync(cancellationToken); + } + // like a failing document, a failed reconciliation never costs the four domains still to sync + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "Failed to reconcile finished TV shows during the reference sync"); + } await SyncDomainAsync(onStageChanged, ReferenceSyncStage.SyncingMovies, cutoff, movieReferenceRepository.FindStaleAsync, (r, ct) => enrichmentService.RefreshMovieReferenceAsync(r, fullFetch, ct), r => r.Id, diff --git a/src/WebApi/ReferenceData/TvShowStatusReconciliationService.cs b/src/WebApi/ReferenceData/TvShowStatusReconciliationService.cs index f1516fc3..8b6cd77f 100644 --- a/src/WebApi/ReferenceData/TvShowStatusReconciliationService.cs +++ b/src/WebApi/ReferenceData/TvShowStatusReconciliationService.cs @@ -13,7 +13,7 @@ namespace Keeptrack.WebApi.ReferenceData; /// so "finished" holds as of now and a regular pass checks it against the episode guide. /// When the linked reference lists an aired episode after the last one watched, the show goes back to and reappears in Watch Next. /// -/// It runs right after , in the same tick or "sync now", so it checks the freshly updated guides. +/// runs it right after the TV domain, in the same tick or "sync now", so it checks the freshly updated guides. /// It works on tenant data, kept apart from the reference refresh, whose only charter is the shared collections. /// /// Only finished shows are touched. diff --git a/test/WebApi.IntegrationTests/Resources/TvShowStatusReconciliationTest.cs b/test/WebApi.IntegrationTests/Resources/TvShowStatusReconciliationTest.cs new file mode 100644 index 00000000..dc1bd6f4 --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/TvShowStatusReconciliationTest.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Keeptrack.WebApi.ReferenceData; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// end to end against real MongoDB: +/// the finished-show query, the batched reference and episode reads, and the status write each pass alone, so this pins them together. +/// +public class TvShowStatusReconciliationTest(KestrelWebAppFactory factory) : DatabaseTestBase(factory) +{ + private static readonly int[] s_episodesPerSeason = [8, 6, 8, 8]; + + [Fact] + public async Task ReconcileFinishedShowsAsync_ReopensAFinishedShowWatchedUpToTheLastSeasonFinale_OnceANewSeasonHasAired() + { + using var scope = Factory.Services.CreateScope(); + var today = DateOnly.FromDateTime(DateTime.Today); + var ownerId = $"owner-{Guid.NewGuid()}"; + var reference = await CreateReferenceAsync(scope, season => season == 4 ? today.AddDays(-13) : today.AddYears(season - 4)); + var show = await CreateShowAsync(scope, ownerId, TvShowStatus.Finished, reference.Id!); + await RecordEpisodesUpToAsync(scope, ownerId, show.Id!, lastSeason: 3); + + var reopened = await scope.ServiceProvider.GetRequiredService().ReconcileFinishedShowsAsync(TestContext.Current.CancellationToken); + + reopened.Should().BeGreaterThanOrEqualTo(1); + var stored = await scope.ServiceProvider.GetRequiredService().FindOneAsync(show.Id!, ownerId, TestContext.Current.CancellationToken); + stored!.State.Should().Be(TvShowStatus.Current); + } + + [Fact] + public async Task ReconcileFinishedShowsAsync_LeavesAFinishedShowAlone_WhileTheNewSeasonHasNotAired() + { + using var scope = Factory.Services.CreateScope(); + var today = DateOnly.FromDateTime(DateTime.Today); + var ownerId = $"owner-{Guid.NewGuid()}"; + var reference = await CreateReferenceAsync(scope, season => season == 4 ? today.AddDays(30) : today.AddYears(season - 4)); + var show = await CreateShowAsync(scope, ownerId, TvShowStatus.Finished, reference.Id!); + await RecordEpisodesUpToAsync(scope, ownerId, show.Id!, lastSeason: 3); + + await scope.ServiceProvider.GetRequiredService().ReconcileFinishedShowsAsync(TestContext.Current.CancellationToken); + + var stored = await scope.ServiceProvider.GetRequiredService().FindOneAsync(show.Id!, ownerId, TestContext.Current.CancellationToken); + stored!.State.Should().Be(TvShowStatus.Finished); + } + + private async Task CreateReferenceAsync(IServiceScope scope, Func airDateOfSeason) + { + var episodes = new List(); + for (var season = 1; season <= s_episodesPerSeason.Length; season++) + { + episodes.AddRange(Enumerable.Range(1, s_episodesPerSeason[season - 1]).Select(episode => new ReferenceEpisodeModel + { + SeasonNumber = season, + EpisodeNumber = episode, + Title = $"S{season}E{episode}", + AirDate = airDateOfSeason(season) + })); + } + + var created = await scope.ServiceProvider.GetRequiredService().UpsertAsync(new TvShowReferenceModel + { + Title = "The Diplomat", + TitleNormalized = "the diplomat", + Year = 2023, + ExternalIds = new Dictionary { ["tmdb"] = TestExternalId.New() }, + Episodes = episodes + }); + TrackDocument("tvshow_reference", created.Id); + return created; + } + + private async Task CreateShowAsync(IServiceScope scope, string ownerId, TvShowStatus state, string referenceId) + { + var repository = scope.ServiceProvider.GetRequiredService(); + var created = await repository.CreateAsync(new TvShowModel { OwnerId = ownerId, Title = "The Diplomat", Year = 2023, State = state, ReferenceId = referenceId }); + TrackCleanup(() => repository.DeleteAsync(created.Id!, ownerId)); + return created; + } + + private async Task RecordEpisodesUpToAsync(IServiceScope scope, string ownerId, string showId, int lastSeason) + { + var repository = scope.ServiceProvider.GetRequiredService(); + for (var season = 1; season <= lastSeason; season++) + { + for (var episode = 1; episode <= s_episodesPerSeason[season - 1]; episode++) + { + var created = await repository.CreateAsync(new EpisodeModel + { + OwnerId = ownerId, + TvShowId = showId, + SeasonNumber = season, + EpisodeNumber = episode, + WatchedAt = DateOnly.FromDateTime(DateTime.Today) + }); + TrackCleanup(() => repository.DeleteAsync(created.Id!, ownerId)); + } + } + } +} diff --git a/test/WebApi.UnitTests/ReferenceData/ReferenceSyncServiceTest.cs b/test/WebApi.UnitTests/ReferenceData/ReferenceSyncServiceTest.cs index bd1e280f..ac01f68f 100644 --- a/test/WebApi.UnitTests/ReferenceData/ReferenceSyncServiceTest.cs +++ b/test/WebApi.UnitTests/ReferenceData/ReferenceSyncServiceTest.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using AwesomeAssertions; @@ -12,6 +13,9 @@ using Moq; using Xunit; +// the Domain status enum, not the Contracts one the WebApi.Contracts.Dto using also imports +using TvShowStatus = Keeptrack.Domain.Models.TvShowStatus; + namespace Keeptrack.WebApi.UnitTests.ReferenceData; [Trait("Category", "UnitTests")] @@ -28,6 +32,7 @@ public class ReferenceSyncServiceTest private readonly Mock _bookRepository = new(); private readonly Mock _videoGameRepository = new(); private readonly Mock _albumRepository = new(); + private readonly Mock _episodeRepository = new(); private ReferenceSyncService CreateService(FakeTmdbClient tmdbClient) { @@ -46,10 +51,12 @@ private ReferenceSyncService CreateService(FakeTmdbClient tmdbClient) _bookReferenceRepository.Object, _videoGameReferenceRepository.Object, _albumReferenceRepository.Object, _tvShowRepository.Object, _movieRepository.Object, _bookRepository.Object, _videoGameRepository.Object, _albumRepository.Object, appSettingRepository.Object, new RatingSourceOptions(videoGameClients), NullLogger.Instance); + var reconciliationService = new TvShowStatusReconciliationService( + _tvShowRepository.Object, _episodeRepository.Object, _tvShowReferenceRepository.Object, NullLogger.Instance); return new ReferenceSyncService( _tvShowReferenceRepository.Object, _movieReferenceRepository.Object, _bookReferenceRepository.Object, _videoGameReferenceRepository.Object, _albumReferenceRepository.Object, - enrichmentService, NullLogger.Instance); + enrichmentService, reconciliationService, NullLogger.Instance); } /// @@ -161,6 +168,58 @@ public async Task SyncStaleReferencesAsync_TrustsTmdbsChangeCheck_OnThePeriodicP tmdbClient.ChangesRequested.Should().BeEquivalentTo(["42", "603"]); } + /// + /// A forced pass spends far longer on movies, books, video games and albums than on TV, + /// and finished shows only depend on the TV guides, so they are reopened before the next domain starts. + /// + [Fact] + public async Task SyncStaleReferencesAsync_ReopensFinishedShows_RightAfterTheTvShowsAndBeforeTheMovies() + { + _tvShowReferenceRepository.Setup(r => r.FindStaleAsync(It.IsAny(), It.IsAny())).ReturnsAsync([]); + _movieReferenceRepository.Setup(r => r.FindStaleAsync(It.IsAny(), It.IsAny())).ReturnsAsync([]); + _tvShowRepository.Setup(r => r.FindFinishedLinkedShowsAsync()).ReturnsAsync([ + new TvShowModel { Id = "show-1", OwnerId = "owner", Title = "The Diplomat", State = TvShowStatus.Finished, ReferenceId = "reference-1" } + ]); + _episodeRepository.Setup(r => r.FindByShowIdsAsync("owner", It.IsAny>())) + .ReturnsAsync([new EpisodeModel { OwnerId = "owner", TvShowId = "show-1", SeasonNumber = 3, EpisodeNumber = 8 }]); + _tvShowReferenceRepository.Setup(r => r.FindByIdsAsync(It.IsAny>())).ReturnsAsync([ + new TvShowReferenceModel + { + Id = "reference-1", Title = "The Diplomat", TitleNormalized = "the diplomat", ExternalIds = new Dictionary(), + Episodes = [new ReferenceEpisodeModel { SeasonNumber = 4, EpisodeNumber = 1, Title = "S4E1", AirDate = new DateOnly(2026, 10, 15) }] + } + ]); + var reopenedBeforeMovies = false; + var service = CreateService(FakeTmdbClient.Empty()); + + var result = await service.SyncStaleReferencesAsync(TimeSpan.Zero, stage => + { + if (stage == ReferenceSyncStage.SyncingMovies) + { + reopenedBeforeMovies = _tvShowRepository.Invocations.Any(i => i.Method.Name == nameof(ITvShowRepository.UpdateAsync)); + } + return Task.CompletedTask; + }, + cancellationToken: TestContext.Current.CancellationToken); + + reopenedBeforeMovies.Should().BeTrue(); + result.FinishedShowsReopened.Should().Be(1); + _tvShowRepository.Verify(r => r.UpdateAsync("show-1", It.Is(s => s.State == TvShowStatus.Current), "owner", It.IsAny()), Times.Once); + } + + [Fact] + public async Task SyncStaleReferencesAsync_StillSyncsTheOtherDomains_WhenTheReconciliationFails() + { + _tvShowReferenceRepository.Setup(r => r.FindStaleAsync(It.IsAny(), It.IsAny())).ReturnsAsync([]); + _movieReferenceRepository.Setup(r => r.FindStaleAsync(It.IsAny(), It.IsAny())).ReturnsAsync([]); + _tvShowRepository.Setup(r => r.FindFinishedLinkedShowsAsync()).ThrowsAsync(new InvalidOperationException("Simulated MongoDB failure.")); + var service = CreateService(FakeTmdbClient.Empty()); + + await service.SyncStaleReferencesAsync(TimeSpan.Zero, cancellationToken: TestContext.Current.CancellationToken); + + _albumReferenceRepository.Verify(r => r.FindStaleAsync(It.IsAny(), It.IsAny()), Times.Once); + } + [Fact] public async Task SyncStaleReferencesAsync_ContinuesPastAFailedReference_AndStillProcessesTheRest() { diff --git a/test/WebApi.UnitTests/ReferenceData/TvShowStatusReconciliationServiceTest.cs b/test/WebApi.UnitTests/ReferenceData/TvShowStatusReconciliationServiceTest.cs index 16d52224..47528b03 100644 --- a/test/WebApi.UnitTests/ReferenceData/TvShowStatusReconciliationServiceTest.cs +++ b/test/WebApi.UnitTests/ReferenceData/TvShowStatusReconciliationServiceTest.cs @@ -137,6 +137,58 @@ public async Task LeavesShowAlone_WhenTheOnlyUnwatchedEpisodeIsBeforeTheLastWatc reopened.Should().Be(0); } + [Fact] + public async Task LeavesShowAlone_WhenTheLatestWatchedIsTheFinale_EvenIfAnEarlierEpisodeWasRewatchedSince() + { + // the latest viewed episode is the furthest in (season, episode) order, never the most recent watch date + var rewatched = Episode("show-1", 1, 1); + rewatched.WatchedAt = new DateOnly(2026, 10, 1); + var finale = Episode("show-1", 3, 8); + finale.WatchedAt = new DateOnly(2025, 11, 1); + Setup( + [FinishedShow("show-1")], + [finale, rewatched], + Reference("ref-1", RefEpisode(1, 1, new DateOnly(2023, 4, 20)), RefEpisode(1, 2, new DateOnly(2023, 4, 20)), RefEpisode(3, 8, new DateOnly(2025, 10, 16)))); + var service = CreateService(); + + var reopened = await service.ReconcileFinishedShowsAsync(TestContext.Current.CancellationToken); + + reopened.Should().Be(0); + } + + [Fact] + public async Task Reopens_FinishedShow_WhenANewSeasonAired_EvenIfAnEarlierEpisodeWasRewatchedSince() + { + var rewatched = Episode("show-1", 1, 1); + rewatched.WatchedAt = new DateOnly(2026, 10, 1); + var finale = Episode("show-1", 3, 8); + finale.WatchedAt = new DateOnly(2025, 11, 1); + Setup( + [FinishedShow("show-1")], + [finale, rewatched], + Reference("ref-1", RefEpisode(1, 1, new DateOnly(2023, 4, 20)), RefEpisode(3, 8, new DateOnly(2025, 10, 16)), RefEpisode(4, 1, new DateOnly(2026, 10, 15)))); + var service = CreateService(); + + var reopened = await service.ReconcileFinishedShowsAsync(TestContext.Current.CancellationToken); + + reopened.Should().Be(1); + } + + [Fact] + public async Task Reopens_FinishedShow_WhenTheNewerEpisodeHasNoAirDate() + { + // TMDB leaves the date of an aired episode unset at times, and an unknown date is never read as "not aired yet" + Setup( + [FinishedShow("show-1")], + [Episode("show-1", 3, 8)], + Reference("ref-1", RefEpisode(3, 8, new DateOnly(2025, 10, 16)), RefEpisode(4, 1))); + var service = CreateService(); + + var reopened = await service.ReconcileFinishedShowsAsync(TestContext.Current.CancellationToken); + + reopened.Should().Be(1); + } + [Fact] public async Task LeavesShowAlone_WhenAlreadyCaughtUpWithTheReferenceGuide() {