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()
{