fix(cache): coalesce playback metadata fetches

This commit is contained in:
joshpatra committed 2026-07-26 21:28:49 -04:00
1 parent 348c422ffb
commit 41ddc722ac
3 files changed
+80 -2

No files matched your search

@@ -79,6 +79,31 @@ public sealed class ExternalPlaybackMetadataResolverTests
cache.GetKeysByPattern("negative:*"));
}
[Fact]
public async Task ConcurrentMetadataRequestsShareOneProviderFetch()
{
var release = new TaskCompletionSource<Song?>(
TaskCreationOptions.RunContinuationsAsynchronously);
var service = new Mock<IMusicMetadataService>();
service.Setup(item => item.GetSongAsync("deezer", "42", It.IsAny<CancellationToken>()))
.Returns(release.Task);
var resolver = new ExternalPlaybackMetadataResolver(
service.Object,
new TestMemoryApplicationCache(),
new StubHttpClientFactory(new HttpClient()),
NullLogger<ExternalPlaybackMetadataResolver>.Instance);
var first = resolver.ResolveAsync("ext-deezer-song-42", CancellationToken.None);
var second = resolver.ResolveAsync("ext-deezer-song-42", CancellationToken.None);
release.SetResult(new Song { Title = "Shared", Artist = "Artist" });
var results = await Task.WhenAll(first, second);
Assert.All(results, result => Assert.Equal("Shared", result!.Title));
service.Verify(
item => item.GetSongAsync("deezer", "42", It.IsAny<CancellationToken>()),
Times.Once);
}
private sealed class StubHttpClientFactory(HttpClient client) : IHttpClientFactory
{
public HttpClient CreateClient(string name) => client;
@@ -1,3 +1,5 @@
using System.Collections.Concurrent;
namespace allstarr.Services.Common;
public sealed class ExternalPlaybackMetadataResolver(
@@ -9,6 +11,8 @@ public sealed class ExternalPlaybackMetadataResolver(
private const int MaximumArtworkBytes = 5 * 1024 * 1024;
private static readonly TimeSpan MetadataCacheDuration = TimeSpan.FromMinutes(10);
private static readonly TimeSpan FailureCacheDuration = TimeSpan.FromSeconds(30);
private readonly ConcurrentDictionary<string, Lazy<Task<PlaybackTrackMetadata?>>> _inflight =
new(StringComparer.Ordinal);
public async Task<PlaybackTrackMetadata?> ResolveAsync(string itemId, CancellationToken cancellationToken)
{
@@ -21,11 +25,32 @@ public sealed class ExternalPlaybackMetadataResolver(
var cached = await cache.GetAsync<PlaybackMetadataCacheEntry>(cacheKey);
if (cached != null) return cached.Metadata;
var pending = _inflight.GetOrAdd(
cacheKey,
_ => new Lazy<Task<PlaybackTrackMetadata?>>(
() => ResolveUncachedAsync(identity.Value, cacheKey, negativeKey, cancellationToken),
LazyThreadSafetyMode.ExecutionAndPublication));
try
{
return await pending.Value.WaitAsync(cancellationToken);
}
finally
{
_inflight.TryRemove(new(cacheKey, pending));
}
}
private async Task<PlaybackTrackMetadata?> ResolveUncachedAsync(
(string Provider, string ExternalId) identity,
string cacheKey,
string negativeKey,
CancellationToken cancellationToken)
{
PlaybackTrackMetadata? metadata = null;
try
{
var song = await metadataService.GetSongAsync(
identity.Value.Provider, identity.Value.ExternalId, cancellationToken);
identity.Provider, identity.ExternalId, cancellationToken);
if (song != null)
{
metadata = new(song.Title, song.Artist, song.Album,
@@ -38,7 +63,11 @@ public sealed class ExternalPlaybackMetadataResolver(
}
catch (Exception ex)
{
logger.LogDebug(ex, "Unable to resolve external playback metadata for {ItemId}", itemId);
logger.LogDebug(
ex,
"Unable to resolve {Provider} playback metadata for {ExternalId}",
identity.Provider,
identity.ExternalId);
}
if (metadata == null)
@@ -1,5 +1,6 @@
using System.Net.Http.Headers;
using System.Text.Json;
using System.Collections.Concurrent;
using allstarr.Models.Settings;
using allstarr.Services.Common;
using Microsoft.Extensions.Options;
@@ -16,6 +17,8 @@ public sealed class JellyfinPlaybackMetadataResolver : IPlaybackMetadataResolver
private readonly JellyfinSettings _settings;
private readonly ILogger<JellyfinPlaybackMetadataResolver> _logger;
private readonly IApplicationCache _cache;
private readonly ConcurrentDictionary<string, Lazy<Task<PlaybackTrackMetadata?>>> _inflight =
new(StringComparer.Ordinal);
public JellyfinPlaybackMetadataResolver(
IHttpClientFactory httpClientFactory,
@@ -46,6 +49,27 @@ public sealed class JellyfinPlaybackMetadataResolver : IPlaybackMetadataResolver
var cached = await _cache.GetAsync<MetadataCacheEntry>(cacheKey);
if (cached != null) return cached.Metadata;
var pending = _inflight.GetOrAdd(
cacheKey,
_ => new Lazy<Task<PlaybackTrackMetadata?>>(
() => ResolveUncachedAsync(itemId, cacheKey, negativeKey, cancellationToken),
LazyThreadSafetyMode.ExecutionAndPublication));
try
{
return await pending.Value.WaitAsync(cancellationToken);
}
finally
{
_inflight.TryRemove(new(cacheKey, pending));
}
}
private async Task<PlaybackTrackMetadata?> ResolveUncachedAsync(
string itemId,
string cacheKey,
string negativeKey,
CancellationToken cancellationToken)
{
PlaybackTrackMetadata? metadata = null;
try
{