mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-08 14:05:02 -04:00
fix(cache): coalesce playback metadata fetches
This commit is contained in:
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
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user