diff --git a/src/WatchBack.Infrastructure/ThoughtProviders/BlueskyThoughtProvider.cs b/src/WatchBack.Infrastructure/ThoughtProviders/BlueskyThoughtProvider.cs index e8b31dd..4a65c53 100644 --- a/src/WatchBack.Infrastructure/ThoughtProviders/BlueskyThoughtProvider.cs +++ b/src/WatchBack.Infrastructure/ThoughtProviders/BlueskyThoughtProvider.cs @@ -62,6 +62,8 @@ public string GetCacheKey(MediaContext mediaContext) return cached; } + progress = InitialLoadProgress.ForFetch(cache, cacheKey, progress); + try { string? token = await GetAccessTokenAsync(ct); diff --git a/src/WatchBack.Infrastructure/ThoughtProviders/InitialLoadProgress.cs b/src/WatchBack.Infrastructure/ThoughtProviders/InitialLoadProgress.cs new file mode 100644 index 0000000..12e8a90 --- /dev/null +++ b/src/WatchBack.Infrastructure/ThoughtProviders/InitialLoadProgress.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Caching.Memory; + +using WatchBack.Core.Models; + +namespace WatchBack.Infrastructure.ThoughtProviders; + +/// +/// Limits sync progress reporting to a provider's first fetch for a given media item. +/// The frontend shows the loading bar whenever it sees intermediate progress, so +/// background retries (after a failure) and TTL refreshes must stay silent: the bar is +/// meant for the initial bulk content load only. +/// +internal static class InitialLoadProgress +{ + private static readonly TimeSpan s_markerTtl = TimeSpan.FromHours(24); + + /// + /// Returns the first time is fetched + /// and null on every later fetch (retry or refresh) within the marker's lifetime. + /// + public static IProgress? ForFetch( + IMemoryCache cache, string cacheKey, IProgress? progress) + { + string markerKey = $"{cacheKey}:initial-load"; + if (cache.TryGetValue(markerKey, out _)) + { + return null; + } + + cache.Set(markerKey, true, s_markerTtl); + return progress; + } +} diff --git a/src/WatchBack.Infrastructure/ThoughtProviders/LemmyThoughtProvider.cs b/src/WatchBack.Infrastructure/ThoughtProviders/LemmyThoughtProvider.cs index b7d1079..9348c84 100644 --- a/src/WatchBack.Infrastructure/ThoughtProviders/LemmyThoughtProvider.cs +++ b/src/WatchBack.Infrastructure/ThoughtProviders/LemmyThoughtProvider.cs @@ -61,6 +61,8 @@ public string GetCacheKey(MediaContext mediaContext) return cached; } + progress = InitialLoadProgress.ForFetch(cache, cacheKey, progress); + int reported = 0; try { diff --git a/src/WatchBack.Infrastructure/ThoughtProviders/RedditThoughtProvider.cs b/src/WatchBack.Infrastructure/ThoughtProviders/RedditThoughtProvider.cs index f429c53..1349a84 100644 --- a/src/WatchBack.Infrastructure/ThoughtProviders/RedditThoughtProvider.cs +++ b/src/WatchBack.Infrastructure/ThoughtProviders/RedditThoughtProvider.cs @@ -43,6 +43,8 @@ public sealed class RedditThoughtProvider( @"\bS(\d{1,2})E\d+\b|\b(\d{1,2})x\d+\b|\bSeason\s+(\d+)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly TimeSpan s_failureCacheTtl = TimeSpan.FromSeconds(60); + private static readonly ConcurrentDictionary<(int, int), Regex[]> s_episodeRegexCache = new(); private readonly RedditOptions _options = options.Value; @@ -78,6 +80,8 @@ public string GetCacheKey(MediaContext mediaContext) return cached; } + progress = InitialLoadProgress.ForFetch(cache, cacheKey, progress); + IReadOnlyList mappedSubreddits = mappingService.GetSubreddits(mediaContext); IReadOnlyList subredditsToSearch = mappedSubreddits.Count > 0 ? mappedSubreddits @@ -208,12 +212,12 @@ public string GetCacheKey(MediaContext mediaContext) if (submissions.Count == 0) { ThoughtResult emptyResult = s_empty; - // Only cache confirmed archive misses (at least one 2xx from PullPush). - // Timeout/network failures are transient — don't prevent future retries. - if (anySpecSucceeded) - { - cache.Set(cacheKey, emptyResult, TimeSpan.FromSeconds(_options.CacheTtlSeconds)); - } + // Confirmed archive misses (at least one 2xx from PullPush) get the full TTL. + // Timeout/network/429 failures are transient, so they're only cached briefly: + // retrying every poll cycle would re-report progress ticks and flash the + // frontend loading bar for as long as PullPush keeps failing. + cache.Set(cacheKey, emptyResult, + anySpecSucceeded ? TimeSpan.FromSeconds(_options.CacheTtlSeconds) : s_failureCacheTtl); return emptyResult; } diff --git a/src/WatchBack.Infrastructure/ThoughtProviders/TraktThoughtProvider.cs b/src/WatchBack.Infrastructure/ThoughtProviders/TraktThoughtProvider.cs index 6037a42..a1fbf48 100644 --- a/src/WatchBack.Infrastructure/ThoughtProviders/TraktThoughtProvider.cs +++ b/src/WatchBack.Infrastructure/ThoughtProviders/TraktThoughtProvider.cs @@ -29,6 +29,10 @@ public sealed class TraktThoughtProvider( private static readonly ThoughtResult s_empty = new("Trakt", null, null, null, [], null); + // Failed lookups (429s, unresolved slugs, transient errors) are cached briefly so + // the 5-second poll loop doesn't redo the work, and re-tick, every cycle. + private static readonly TimeSpan s_failureCacheTtl = TimeSpan.FromSeconds(60); + private readonly TraktOptions _options = options.Value; public DataProviderMetadata Metadata => new( @@ -73,6 +77,8 @@ public string GetCacheKey(MediaContext mediaContext) return cached; } + progress = InitialLoadProgress.ForFetch(cache, cacheKey, progress); + try { EpisodeContext? episode = mediaContext as EpisodeContext; @@ -84,7 +90,7 @@ public string GetCacheKey(MediaContext mediaContext) if (movieId == null) { logger.LogWarning("Trakt: no movie found for '{Title}'", mediaContext.Title); - return s_empty; + return CacheFailure(cacheKey); } commentsUrl = $"https://api.trakt.tv/movies/{movieId}/comments/newest"; @@ -95,7 +101,7 @@ public string GetCacheKey(MediaContext mediaContext) if (showId == null) { logger.LogWarning("Trakt: no show found for '{Title}'", mediaContext.Title); - return s_empty; + return CacheFailure(cacheKey); } commentsUrl = @@ -110,7 +116,7 @@ public string GetCacheKey(MediaContext mediaContext) { logger.LogWarning("Trakt comments failed: HTTP {Status} for '{Title}'", (int)commentsResponse.StatusCode, mediaContext.Title); - return s_empty; + return CacheFailure(cacheKey); } string commentsContent = await commentsResponse.Content.ReadAsStringAsync(ct); @@ -148,7 +154,7 @@ public string GetCacheKey(MediaContext mediaContext) catch (Exception ex) { logger.LogError(ex, "Trakt thought fetch failed"); - return s_empty; + return CacheFailure(cacheKey); } finally { @@ -156,6 +162,12 @@ public string GetCacheKey(MediaContext mediaContext) } } + private ThoughtResult CacheFailure(string cacheKey) + { + cache.Set(cacheKey, s_empty, s_failureCacheTtl); + return s_empty; + } + public async Task GetServiceHealthAsync(CancellationToken ct = default) { try diff --git a/tests/WatchBack.Infrastructure.Tests/InitialLoadProgressTests.cs b/tests/WatchBack.Infrastructure.Tests/InitialLoadProgressTests.cs new file mode 100644 index 0000000..3fbf36c --- /dev/null +++ b/tests/WatchBack.Infrastructure.Tests/InitialLoadProgressTests.cs @@ -0,0 +1,49 @@ +using FluentAssertions; + +using Microsoft.Extensions.Caching.Memory; + +using NSubstitute; + +using WatchBack.Core.Models; +using WatchBack.Infrastructure.ThoughtProviders; + +using Xunit; + +namespace WatchBack.Infrastructure.Tests; + +public class InitialLoadProgressTests : IDisposable +{ + private readonly MemoryCache _cache = new(new MemoryCacheOptions()); + + public void Dispose() + { + _cache.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public void ForFetch_FirstCall_ReturnsProgress() + { + IProgress progress = Substitute.For>(); + + InitialLoadProgress.ForFetch(_cache, "key", progress).Should().BeSameAs(progress); + } + + [Fact] + public void ForFetch_SecondCallForSameKey_ReturnsNull() + { + IProgress progress = Substitute.For>(); + InitialLoadProgress.ForFetch(_cache, "key", progress); + + InitialLoadProgress.ForFetch(_cache, "key", progress).Should().BeNull(); + } + + [Fact] + public void ForFetch_DifferentKey_ReturnsProgress() + { + IProgress progress = Substitute.For>(); + InitialLoadProgress.ForFetch(_cache, "episode-1", progress); + + InitialLoadProgress.ForFetch(_cache, "episode-2", progress).Should().BeSameAs(progress); + } +} diff --git a/tests/WatchBack.Infrastructure.Tests/RedditThoughtProviderTests.cs b/tests/WatchBack.Infrastructure.Tests/RedditThoughtProviderTests.cs index 85901c9..f537e86 100644 --- a/tests/WatchBack.Infrastructure.Tests/RedditThoughtProviderTests.cs +++ b/tests/WatchBack.Infrastructure.Tests/RedditThoughtProviderTests.cs @@ -611,6 +611,62 @@ public async Task GetThoughtsAsync_CreatedUtcAsString_DeserializesWithoutThrowin capturedThoughts.Single().CreatedAt.Should().Be(DateTimeOffset.FromUnixTimeSeconds(1401753700)); } + // Regression: when every PullPush search fails (e.g. 429), the empty result used to go + // uncached, so each poll cycle redid the searches and re-ticked, flashing the loading bar. + [Fact] + public async Task GetThoughtsAsync_AfterAllSearchesFail_ReportsNoTicksOnNextCall() + { + int requestCount = 0; + MockHttpMessageHandler handler = new(() => + { + requestCount++; + return new HttpResponseMessage(HttpStatusCode.TooManyRequests); + }); + HttpClient client = new(handler) { BaseAddress = new Uri("https://api.pullpush.io") }; + RedditThoughtProvider provider = new(client, new OptionsSnapshotStub(_options), _cache, + Substitute.For(), NoMappings(), NullLogger.Instance); + + EpisodeContext mediaContext = new("Breaking Bad", DateTimeOffset.UtcNow, "Pilot", 1, 1); + await provider.GetThoughtsAsync(mediaContext); + int requestsAfterFirst = requestCount; + + ConcurrentBag ticks = new(); + await provider.GetThoughtsAsync(mediaContext, new CapturingProgress(ticks)); + + ticks.Should().BeEmpty("a cached failure does no work and must not arm the frontend loading bar"); + requestCount.Should().Be(requestsAfterFirst); + } + + // The loading bar is for the initial bulk load only: once a fetch has been attempted for an + // episode, a retry after the cached failure expires must run without reporting progress. + [Fact] + public async Task GetThoughtsAsync_RetryAfterFailureExpires_ReportsNoTicks() + { + int requestCount = 0; + MockHttpMessageHandler handler = new(() => + { + requestCount++; + return new HttpResponseMessage(HttpStatusCode.TooManyRequests); + }); + HttpClient client = new(handler) { BaseAddress = new Uri("https://api.pullpush.io") }; + RedditThoughtProvider provider = new(client, new OptionsSnapshotStub(_options), _cache, + Substitute.For(), NoMappings(), NullLogger.Instance); + + EpisodeContext mediaContext = new("Breaking Bad", DateTimeOffset.UtcNow, "Pilot", 1, 1); + ConcurrentBag initialTicks = new(); + await provider.GetThoughtsAsync(mediaContext, new CapturingProgress(initialTicks)); + initialTicks.Should().NotBeEmpty("the initial load reports progress"); + int requestsAfterFirst = requestCount; + + _cache.Remove(provider.GetCacheKey(mediaContext)); // simulate the failure entry's TTL expiring + + ConcurrentBag retryTicks = new(); + await provider.GetThoughtsAsync(mediaContext, new CapturingProgress(retryTicks)); + + requestCount.Should().BeGreaterThan(requestsAfterFirst, "the retry still hits the network"); + retryTicks.Should().BeEmpty("retries are background work and must not arm the loading bar"); + } + [Fact] public async Task GetThoughtsAsync_NullProgress_DoesNotThrow() { diff --git a/tests/WatchBack.Infrastructure.Tests/TraktThoughtProviderTests.cs b/tests/WatchBack.Infrastructure.Tests/TraktThoughtProviderTests.cs index 6d6d896..b2770ab 100644 --- a/tests/WatchBack.Infrastructure.Tests/TraktThoughtProviderTests.cs +++ b/tests/WatchBack.Infrastructure.Tests/TraktThoughtProviderTests.cs @@ -287,6 +287,63 @@ public async Task GetThoughtsAsync_OnCacheHit_ReportsNoTicks() ticks.Should().BeEmpty("a cached cycle does no work and must not arm the frontend loading bar"); } + // Regression: when Trakt rate-limits (429) slug resolution, the failed lookup used to + // go uncached, so every poll cycle retried it and re-ticked, flashing the loading bar. + [Fact] + public async Task GetThoughtsAsync_AfterRateLimitedLookup_ReportsNoTicksOnNextCall() + { + EpisodeContext mediaContext = new("Love Is Blind", DateTimeOffset.UtcNow, "Pilot", 1, 1); + int requestCount = 0; + MockHttpMessageHandler handler = new(() => + { + requestCount++; + return new HttpResponseMessage(HttpStatusCode.TooManyRequests); + }); + HttpClient client = new(handler) { BaseAddress = new Uri("https://api.trakt.tv") }; + + TraktThoughtProvider provider = new(client, new OptionsSnapshotStub(_options), _cache, + Substitute.For(), NullLogger.Instance); + + await provider.GetThoughtsAsync(mediaContext); + int requestsAfterFirst = requestCount; + + ConcurrentBag ticks = new(); + await provider.GetThoughtsAsync(mediaContext, new CapturingProgress(ticks)); + + ticks.Should().BeEmpty("a cached failure does no work and must not arm the frontend loading bar"); + requestCount.Should().Be(requestsAfterFirst); + } + + // The loading bar is for the initial bulk load only: once a fetch has been attempted for an + // episode, a retry after the cached failure expires must run without reporting progress. + [Fact] + public async Task GetThoughtsAsync_RetryAfterFailureExpires_ReportsNoTicks() + { + EpisodeContext mediaContext = new("Love Is Blind", DateTimeOffset.UtcNow, "Pilot", 1, 1); + int requestCount = 0; + MockHttpMessageHandler handler = new(() => + { + requestCount++; + return new HttpResponseMessage(HttpStatusCode.TooManyRequests); + }); + HttpClient client = new(handler) { BaseAddress = new Uri("https://api.trakt.tv") }; + TraktThoughtProvider provider = new(client, new OptionsSnapshotStub(_options), _cache, + Substitute.For(), NullLogger.Instance); + + ConcurrentBag initialTicks = new(); + await provider.GetThoughtsAsync(mediaContext, new CapturingProgress(initialTicks)); + initialTicks.Should().ContainSingle("the initial load reports progress"); + int requestsAfterFirst = requestCount; + + _cache.Remove(provider.GetCacheKey(mediaContext)); // simulate the failure entry's TTL expiring + + ConcurrentBag retryTicks = new(); + await provider.GetThoughtsAsync(mediaContext, new CapturingProgress(retryTicks)); + + requestCount.Should().BeGreaterThan(requestsAfterFirst, "the retry still hits the network"); + retryTicks.Should().BeEmpty("retries are background work and must not arm the loading bar"); + } + [Fact] public async Task GetThoughtsAsync_WhenNotConfigured_ReportsNoTicks() {