Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ public string GetCacheKey(MediaContext mediaContext)
return cached;
}

progress = InitialLoadProgress.ForFetch(cache, cacheKey, progress);

try
{
string? token = await GetAccessTokenAsync(ct);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using Microsoft.Extensions.Caching.Memory;

using WatchBack.Core.Models;

namespace WatchBack.Infrastructure.ThoughtProviders;

/// <summary>
/// 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.
/// </summary>
internal static class InitialLoadProgress
{
private static readonly TimeSpan s_markerTtl = TimeSpan.FromHours(24);

/// <summary>
/// Returns <paramref name="progress" /> the first time <paramref name="cacheKey" /> is fetched
/// and <c>null</c> on every later fetch (retry or refresh) within the marker's lifetime.
/// </summary>
public static IProgress<SyncProgressTick>? ForFetch(
IMemoryCache cache, string cacheKey, IProgress<SyncProgressTick>? progress)
{
string markerKey = $"{cacheKey}:initial-load";
if (cache.TryGetValue(markerKey, out _))
{
return null;
}

cache.Set(markerKey, true, s_markerTtl);
return progress;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ public string GetCacheKey(MediaContext mediaContext)
return cached;
}

progress = InitialLoadProgress.ForFetch(cache, cacheKey, progress);

int reported = 0;
try
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -78,6 +80,8 @@ public string GetCacheKey(MediaContext mediaContext)
return cached;
}

progress = InitialLoadProgress.ForFetch(cache, cacheKey, progress);

IReadOnlyList<string> mappedSubreddits = mappingService.GetSubreddits(mediaContext);
IReadOnlyList<string> subredditsToSearch = mappedSubreddits.Count > 0
? mappedSubreddits
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -73,6 +77,8 @@ public string GetCacheKey(MediaContext mediaContext)
return cached;
}

progress = InitialLoadProgress.ForFetch(cache, cacheKey, progress);

try
{
EpisodeContext? episode = mediaContext as EpisodeContext;
Expand All @@ -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";
Expand All @@ -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 =
Expand All @@ -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);
Expand Down Expand Up @@ -148,14 +154,20 @@ public string GetCacheKey(MediaContext mediaContext)
catch (Exception ex)
{
logger.LogError(ex, "Trakt thought fetch failed");
return s_empty;
return CacheFailure(cacheKey);
}
finally
{
progress?.Report(new SyncProgressTick(1, "Trakt"));
}
}

private ThoughtResult CacheFailure(string cacheKey)
{
cache.Set(cacheKey, s_empty, s_failureCacheTtl);
return s_empty;
}

public async Task<ServiceHealth> GetServiceHealthAsync(CancellationToken ct = default)
{
try
Expand Down
49 changes: 49 additions & 0 deletions tests/WatchBack.Infrastructure.Tests/InitialLoadProgressTests.cs
Original file line number Diff line number Diff line change
@@ -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<SyncProgressTick> progress = Substitute.For<IProgress<SyncProgressTick>>();

InitialLoadProgress.ForFetch(_cache, "key", progress).Should().BeSameAs(progress);
}

[Fact]
public void ForFetch_SecondCallForSameKey_ReturnsNull()
{
IProgress<SyncProgressTick> progress = Substitute.For<IProgress<SyncProgressTick>>();
InitialLoadProgress.ForFetch(_cache, "key", progress);

InitialLoadProgress.ForFetch(_cache, "key", progress).Should().BeNull();
}

[Fact]
public void ForFetch_DifferentKey_ReturnsProgress()
{
IProgress<SyncProgressTick> progress = Substitute.For<IProgress<SyncProgressTick>>();
InitialLoadProgress.ForFetch(_cache, "episode-1", progress);

InitialLoadProgress.ForFetch(_cache, "episode-2", progress).Should().BeSameAs(progress);
}
}
56 changes: 56 additions & 0 deletions tests/WatchBack.Infrastructure.Tests/RedditThoughtProviderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RedditOptions>(_options), _cache,
Substitute.For<IReplyTreeBuilder>(), NoMappings(), NullLogger<RedditThoughtProvider>.Instance);

EpisodeContext mediaContext = new("Breaking Bad", DateTimeOffset.UtcNow, "Pilot", 1, 1);
await provider.GetThoughtsAsync(mediaContext);
int requestsAfterFirst = requestCount;

ConcurrentBag<SyncProgressTick> 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<RedditOptions>(_options), _cache,
Substitute.For<IReplyTreeBuilder>(), NoMappings(), NullLogger<RedditThoughtProvider>.Instance);

EpisodeContext mediaContext = new("Breaking Bad", DateTimeOffset.UtcNow, "Pilot", 1, 1);
ConcurrentBag<SyncProgressTick> 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<SyncProgressTick> 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()
{
Expand Down
57 changes: 57 additions & 0 deletions tests/WatchBack.Infrastructure.Tests/TraktThoughtProviderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TraktOptions>(_options), _cache,
Substitute.For<IReplyTreeBuilder>(), NullLogger<TraktThoughtProvider>.Instance);

await provider.GetThoughtsAsync(mediaContext);
int requestsAfterFirst = requestCount;

ConcurrentBag<SyncProgressTick> 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<TraktOptions>(_options), _cache,
Substitute.For<IReplyTreeBuilder>(), NullLogger<TraktThoughtProvider>.Instance);

ConcurrentBag<SyncProgressTick> 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<SyncProgressTick> 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()
{
Expand Down
Loading