From 4ebce3907062ade1937440628eebd665440b338d Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 3 May 2026 23:43:01 +0200 Subject: Implement Similarity providers --- .../Library/SimilarItems/SimilarItemsManager.cs | 423 +++++++++++++++++++++ 1 file changed, 423 insertions(+) create mode 100644 Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs (limited to 'Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs') diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs new file mode 100644 index 0000000000..ddafed3d67 --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs @@ -0,0 +1,423 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions.Json; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Querying; +using Microsoft.Extensions.Logging; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// +/// Manages similar items providers and orchestrates similar items operations. +/// +public class SimilarItemsManager : ISimilarItemsManager +{ + private static readonly ConcurrentDictionary _genericMethodCache = new(); + private static readonly MethodInfo _getSimilarItemsInternalMethod = typeof(SimilarItemsManager) + .GetMethod(nameof(GetSimilarItemsInternalAsync), BindingFlags.NonPublic | BindingFlags.Instance)!; + + private readonly ILogger _logger; + private readonly IServerApplicationPaths _appPaths; + private readonly ILibraryManager _libraryManager; + private readonly IFileSystem _fileSystem; + private ISimilarItemsProvider[] _similarItemsProviders = []; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The server application paths. + /// The library manager. + /// The file system. + public SimilarItemsManager( + ILogger logger, + IServerApplicationPaths appPaths, + ILibraryManager libraryManager, + IFileSystem fileSystem) + { + _logger = logger; + _appPaths = appPaths; + _libraryManager = libraryManager; + _fileSystem = fileSystem; + } + + /// + public void AddParts(IEnumerable providers) + { + _similarItemsProviders = providers.ToArray(); + } + + /// + public IReadOnlyList GetSimilarItemsProviders() + where T : BaseItem + { + return _similarItemsProviders + .OfType>() + .Cast() + .Concat(_similarItemsProviders.OfType>()) + .ToList(); + } + + /// + public async Task> GetSimilarItemsAsync( + BaseItem item, + IReadOnlyList excludeArtistIds, + User? user, + DtoOptions dtoOptions, + int? limit, + LibraryOptions? libraryOptions, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(item); + ArgumentNullException.ThrowIfNull(excludeArtistIds); + + var itemType = item.GetType(); + var method = _genericMethodCache.GetOrAdd(itemType, static type => _getSimilarItemsInternalMethod.MakeGenericMethod(type)); + + var task = (Task>)method.Invoke(this, [item, excludeArtistIds, user, dtoOptions, limit, libraryOptions, cancellationToken])!; + return await task.ConfigureAwait(false); + } + + private async Task> GetSimilarItemsInternalAsync( + T item, + IReadOnlyList excludeArtistIds, + User? user, + DtoOptions dtoOptions, + int? limit, + LibraryOptions? libraryOptions, + CancellationToken cancellationToken) + where T : BaseItem + { + var requestedLimit = limit ?? 50; + var itemKind = item.GetBaseItemKind(); + + // Ensure ProviderIds is included in DtoOptions for matching remote provider responses + if (!dtoOptions.Fields.Contains(ItemFields.ProviderIds)) + { + dtoOptions.Fields = dtoOptions.Fields.Concat([ItemFields.ProviderIds]).ToArray(); + } + + // Local providers are always enabled. Remote providers must be explicitly enabled. + var localProviders = _similarItemsProviders.OfType>().Cast().ToList(); + var remoteProviders = _similarItemsProviders.OfType>().Cast(); + var matchingProviders = new List(localProviders); + + var typeOptions = libraryOptions?.GetTypeOptions(typeof(T).Name); + if (typeOptions?.SimilarItemProviders?.Length > 0) + { + matchingProviders.AddRange(remoteProviders + .Where(p => typeOptions.SimilarItemProviders.Contains(p.Name, StringComparer.OrdinalIgnoreCase))); + } + + var orderConfig = typeOptions?.SimilarItemProviderOrder is { Length: > 0 } order + ? order + : typeOptions?.SimilarItemProviders; + var orderedProviders = matchingProviders + .OrderBy(p => GetConfiguredSimilarProviderOrder(orderConfig, p.Name)) + .ToList(); + + var allResults = new List<(BaseItem Item, float Score)>(); + var excludeIds = new HashSet { item.Id }; + foreach (var (providerOrder, provider) in orderedProviders.Index()) + { + if (allResults.Count >= requestedLimit || cancellationToken.IsCancellationRequested) + { + break; + } + + try + { + if (provider is ILocalSimilarItemsProvider localProvider) + { + var query = new SimilarItemsQuery + { + User = user, + Limit = requestedLimit - allResults.Count, + DtoOptions = dtoOptions, + ExcludeItemIds = [.. excludeIds], + ExcludeArtistIds = excludeArtistIds + }; + + var items = await localProvider.GetSimilarItemsAsync(item, query, cancellationToken).ConfigureAwait(false); + + foreach (var (position, resultItem) in items.Index()) + { + if (excludeIds.Add(resultItem.Id)) + { + var score = CalculateScore(null, providerOrder, position); + allResults.Add((resultItem, score)); + } + } + } + else if (provider is IRemoteSimilarItemsProvider remoteProvider) + { + var cachePath = GetSimilarItemsCachePath(provider.Name, typeof(T).Name, item.Id); + + var cachedReferences = await TryReadSimilarItemsCacheAsync(cachePath, cancellationToken).ConfigureAwait(false); + if (cachedReferences is not null) + { + var resolvedItems = ResolveRemoteReferences(cachedReferences, providerOrder, user, dtoOptions, itemKind, excludeIds); + allResults.AddRange(resolvedItems); + continue; + } + + var query = new SimilarItemsQuery + { + User = user, + Limit = requestedLimit - allResults.Count, + DtoOptions = dtoOptions, + ExcludeItemIds = [.. excludeIds], + ExcludeArtistIds = excludeArtistIds + }; + + // Collect references in batches and resolve against local library. + // Stop fetching once we have enough resolved local items. + const int BatchSize = 20; + var remaining = requestedLimit - allResults.Count; + var collectedReferences = new List(); + var pendingBatch = new List(); + + await foreach (var reference in remoteProvider.GetSimilarItemsAsync(item, query, cancellationToken).ConfigureAwait(false)) + { + collectedReferences.Add(reference); + pendingBatch.Add(reference); + + if (pendingBatch.Count >= BatchSize) + { + var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds); + allResults.AddRange(resolvedItems); + remaining -= resolvedItems.Count; + pendingBatch.Clear(); + + if (remaining <= 0) + { + break; + } + } + } + + // Resolve any remaining references in the last partial batch + if (pendingBatch.Count > 0) + { + var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds); + allResults.AddRange(resolvedItems); + } + + if (collectedReferences.Count > 0 && provider.CacheDuration is not null) + { + await SaveSimilarItemsCacheAsync(cachePath, collectedReferences, provider.CacheDuration.Value, cancellationToken).ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Similar items provider {ProviderName} failed for item {ItemId}", provider.Name, item.Id); + } + } + + return allResults + .OrderByDescending(x => x.Score) + .Select(x => x.Item) + .Take(requestedLimit) + .ToList(); + } + + private List<(BaseItem Item, float Score)> ResolveRemoteReferences( + IReadOnlyList references, + int providerOrder, + User? user, + DtoOptions dtoOptions, + BaseItemKind itemKind, + HashSet excludeIds) + { + if (references.Count == 0) + { + return []; + } + + var resolvedById = new Dictionary(); + var providerLookup = new Dictionary<(string ProviderName, string ProviderId), (float? Score, int Position)>(StringTupleComparer.Instance); + + foreach (var (position, match) in references.Index()) + { + var lookupKey = (match.ProviderName, match.ProviderId); + if (!providerLookup.TryGetValue(lookupKey, out var existing)) + { + providerLookup[lookupKey] = (match.Score, position); + } + else if (match.Score > existing.Score || (match.Score == existing.Score && position < existing.Position)) + { + providerLookup[lookupKey] = (match.Score, position); + } + } + + var allProviderIds = providerLookup + .GroupBy(kvp => kvp.Key.ProviderName) + .ToDictionary(g => g.Key, g => g.Select(x => x.Key.ProviderId).ToArray()); + + var query = new InternalItemsQuery(user) + { + HasAnyProviderIds = allProviderIds, + IncludeItemTypes = [itemKind], + DtoOptions = dtoOptions + }; + + var items = _libraryManager.GetItemList(query); + + foreach (var item in items) + { + if (excludeIds.Contains(item.Id) || resolvedById.ContainsKey(item.Id)) + { + continue; + } + + foreach (var providerName in allProviderIds.Keys) + { + if (item.TryGetProviderId(providerName, out var itemProviderId) && providerLookup.TryGetValue((providerName, itemProviderId), out var matchInfo)) + { + var score = CalculateScore(matchInfo.Score, providerOrder, matchInfo.Position); + if (!resolvedById.TryGetValue(item.Id, out var existing) || existing.Score < score) + { + excludeIds.Add(item.Id); + resolvedById[item.Id] = (item, score); + } + + break; + } + } + } + + return [.. resolvedById.Values]; + } + + private static float CalculateScore(float? matchScore, int providerOrder, int position) + { + // Use provider-supplied score if available, otherwise derive from position + var baseScore = matchScore ?? (1.0f - (position * 0.02f)); + + // Apply small boost based on provider order (higher priority providers get small bonus) + var priorityBoost = Math.Max(0, 10 - providerOrder) * 0.005f; + + return Math.Clamp(baseScore + priorityBoost, 0f, 1f); + } + + private static int GetConfiguredSimilarProviderOrder(string[]? orderConfig, string providerName) + { + if (orderConfig is null || orderConfig.Length == 0) + { + return int.MaxValue; + } + + var index = Array.FindIndex(orderConfig, name => string.Equals(name, providerName, StringComparison.OrdinalIgnoreCase)); + return index >= 0 ? index : int.MaxValue; + } + + private string GetSimilarItemsCachePath(string providerName, string baseItemType, Guid itemId) + { + var dataPath = Path.Combine( + _appPaths.CachePath, + $"{providerName.ToLowerInvariant()}-similar-{baseItemType.ToLowerInvariant()}"); + return Path.Combine(dataPath, $"{itemId.ToString("N", CultureInfo.InvariantCulture)}.json"); + } + + private async Task?> TryReadSimilarItemsCacheAsync(string cachePath, CancellationToken cancellationToken) + { + var fileInfo = _fileSystem.GetFileSystemInfo(cachePath); + if (!fileInfo.Exists || fileInfo.Length == 0) + { + return null; + } + + try + { + var stream = File.OpenRead(cachePath); + await using (stream.ConfigureAwait(false)) + { + var cache = await JsonSerializer.DeserializeAsync(stream, JsonDefaults.Options, cancellationToken).ConfigureAwait(false); + if (cache?.References is not null && DateTime.UtcNow < cache.ExpiresAt) + { + return cache.References; + } + } + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Failed to read similar items cache from {CachePath}", cachePath); + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Failed to parse similar items cache from {CachePath}", cachePath); + } + + return null; + } + + private async Task SaveSimilarItemsCacheAsync(string cachePath, List references, TimeSpan cacheDuration, CancellationToken cancellationToken) + { + try + { + var directory = Path.GetDirectoryName(cachePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + var cache = new SimilarItemsCache + { + References = references, + ExpiresAt = DateTime.UtcNow.Add(cacheDuration) + }; + + var stream = File.Create(cachePath); + await using (stream.ConfigureAwait(false)) + { + await JsonSerializer.SerializeAsync(stream, cache, JsonDefaults.Options, cancellationToken).ConfigureAwait(false); + } + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Failed to save similar items cache to {CachePath}", cachePath); + } + } + + private sealed class SimilarItemsCache + { + public List? References { get; set; } + + public DateTime ExpiresAt { get; set; } + } + + private sealed class StringTupleComparer : IEqualityComparer<(string Key, string Value)> + { + public static readonly StringTupleComparer Instance = new(); + + public bool Equals((string Key, string Value) x, (string Key, string Value) y) + => string.Equals(x.Key, y.Key, StringComparison.OrdinalIgnoreCase) && + string.Equals(x.Value, y.Value, StringComparison.OrdinalIgnoreCase); + + public int GetHashCode((string Key, string Value) obj) + => HashCode.Combine( + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Key), + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Value)); + } +} -- cgit v1.2.3 From b1b45199444e369b12844661f09d1cd0830d25f7 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Wed, 13 May 2026 00:58:03 +0200 Subject: Apply review suggestions --- .../SimilarItems/MovieSimilarItemsProvider.cs | 14 ++++++- .../Library/SimilarItems/SimilarItemsManager.cs | 45 +++++++--------------- .../Library/ILocalSimilarItemsProvider.cs | 38 +++++++++++++++++- .../Library/IRemoteSimilarItemsProvider.cs | 38 +++++++++++++++++- .../ListenBrainz/Api/ListenBrainzLabsClient.cs | 36 ++++++++++++++--- 5 files changed, 131 insertions(+), 40 deletions(-) (limited to 'Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs') diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs index f9547c2c38..93aa0574c0 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -15,7 +16,7 @@ namespace Emby.Server.Implementations.Library.SimilarItems; /// /// Provides similar items for movies and trailers. /// -public class MovieSimilarItemsProvider : ILocalSimilarItemsProvider, ILocalSimilarItemsProvider +public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider, ILocalSimilarItemsProvider { private readonly ILibraryManager _libraryManager; private readonly IServerConfigurationManager _serverConfigurationManager; @@ -51,6 +52,17 @@ public class MovieSimilarItemsProvider : ILocalSimilarItemsProvider, ILoc return Task.FromResult(GetSimilarMovieItems(item, query)); } + bool ILocalSimilarItemsProvider.Supports(Type itemType) + => typeof(Movie).IsAssignableFrom(itemType) || typeof(Trailer).IsAssignableFrom(itemType); + + Task> ILocalSimilarItemsProvider.GetSimilarItemsAsync(BaseItem item, SimilarItemsQuery query, CancellationToken cancellationToken) + => item switch + { + Movie movie => GetSimilarItemsAsync(movie, query, cancellationToken), + Trailer trailer => GetSimilarItemsAsync(trailer, query, cancellationToken), + _ => throw new ArgumentException($"Unsupported item type {item.GetType()}", nameof(item)) + }; + private IReadOnlyList GetSimilarMovieItems(BaseItem item, SimilarItemsQuery query) { var includeItemTypes = new List { BaseItemKind.Movie }; diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs index ddafed3d67..b56779cf3f 100644 --- a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs +++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs @@ -1,10 +1,8 @@ using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; -using System.Reflection; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -28,10 +26,6 @@ namespace Emby.Server.Implementations.Library.SimilarItems; /// public class SimilarItemsManager : ISimilarItemsManager { - private static readonly ConcurrentDictionary _genericMethodCache = new(); - private static readonly MethodInfo _getSimilarItemsInternalMethod = typeof(SimilarItemsManager) - .GetMethod(nameof(GetSimilarItemsInternalAsync), BindingFlags.NonPublic | BindingFlags.Instance)!; - private readonly ILogger _logger; private readonly IServerApplicationPaths _appPaths; private readonly ILibraryManager _libraryManager; @@ -67,10 +61,10 @@ public class SimilarItemsManager : ISimilarItemsManager public IReadOnlyList GetSimilarItemsProviders() where T : BaseItem { + var itemType = typeof(T); return _similarItemsProviders - .OfType>() - .Cast() - .Concat(_similarItemsProviders.OfType>()) + .Where(p => (p is ILocalSimilarItemsProvider local && local.Supports(itemType)) + || (p is IRemoteSimilarItemsProvider remote && remote.Supports(itemType))) .ToList(); } @@ -88,22 +82,6 @@ public class SimilarItemsManager : ISimilarItemsManager ArgumentNullException.ThrowIfNull(excludeArtistIds); var itemType = item.GetType(); - var method = _genericMethodCache.GetOrAdd(itemType, static type => _getSimilarItemsInternalMethod.MakeGenericMethod(type)); - - var task = (Task>)method.Invoke(this, [item, excludeArtistIds, user, dtoOptions, limit, libraryOptions, cancellationToken])!; - return await task.ConfigureAwait(false); - } - - private async Task> GetSimilarItemsInternalAsync( - T item, - IReadOnlyList excludeArtistIds, - User? user, - DtoOptions dtoOptions, - int? limit, - LibraryOptions? libraryOptions, - CancellationToken cancellationToken) - where T : BaseItem - { var requestedLimit = limit ?? 50; var itemKind = item.GetBaseItemKind(); @@ -114,11 +92,16 @@ public class SimilarItemsManager : ISimilarItemsManager } // Local providers are always enabled. Remote providers must be explicitly enabled. - var localProviders = _similarItemsProviders.OfType>().Cast().ToList(); - var remoteProviders = _similarItemsProviders.OfType>().Cast(); + var localProviders = _similarItemsProviders + .OfType() + .Where(p => p.Supports(itemType)) + .ToList(); + var remoteProviders = _similarItemsProviders + .OfType() + .Where(p => p.Supports(itemType)); var matchingProviders = new List(localProviders); - var typeOptions = libraryOptions?.GetTypeOptions(typeof(T).Name); + var typeOptions = libraryOptions?.GetTypeOptions(itemType.Name); if (typeOptions?.SimilarItemProviders?.Length > 0) { matchingProviders.AddRange(remoteProviders @@ -143,7 +126,7 @@ public class SimilarItemsManager : ISimilarItemsManager try { - if (provider is ILocalSimilarItemsProvider localProvider) + if (provider is ILocalSimilarItemsProvider localProvider) { var query = new SimilarItemsQuery { @@ -165,9 +148,9 @@ public class SimilarItemsManager : ISimilarItemsManager } } } - else if (provider is IRemoteSimilarItemsProvider remoteProvider) + else if (provider is IRemoteSimilarItemsProvider remoteProvider) { - var cachePath = GetSimilarItemsCachePath(provider.Name, typeof(T).Name, item.Id); + var cachePath = GetSimilarItemsCachePath(provider.Name, itemType.Name, item.Id); var cachedReferences = await TryReadSimilarItemsCacheAsync(cachePath, cancellationToken).ConfigureAwait(false); if (cachedReferences is not null) diff --git a/MediaBrowser.Controller/Library/ILocalSimilarItemsProvider.cs b/MediaBrowser.Controller/Library/ILocalSimilarItemsProvider.cs index 9bf0121f5f..b8e41ec810 100644 --- a/MediaBrowser.Controller/Library/ILocalSimilarItemsProvider.cs +++ b/MediaBrowser.Controller/Library/ILocalSimilarItemsProvider.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -5,12 +6,38 @@ using MediaBrowser.Controller.Entities; namespace MediaBrowser.Controller.Library; +/// +/// Provides similar items from the local library. +/// Returns fully resolved BaseItems directly - no additional resolution needed. +/// +public interface ILocalSimilarItemsProvider : ISimilarItemsProvider +{ + /// + /// Determines whether the provider can handle items of the specified type. + /// + /// The item type. + /// true if the provider handles this item type; otherwise false. + bool Supports(Type itemType); + + /// + /// Gets similar items from the local library. + /// + /// The source item to find similar items for. + /// The query options (user, limit, exclusions, etc.). + /// Cancellation token. + /// The list of similar items from the library. + Task> GetSimilarItemsAsync( + BaseItem item, + SimilarItemsQuery query, + CancellationToken cancellationToken); +} + /// /// Provides similar items from the local library for a specific item type. /// Returns fully resolved BaseItems directly - no additional resolution needed. /// /// The type of item this provider handles. -public interface ILocalSimilarItemsProvider : ISimilarItemsProvider +public interface ILocalSimilarItemsProvider : ILocalSimilarItemsProvider where TItemType : BaseItem { /// @@ -24,4 +51,13 @@ public interface ILocalSimilarItemsProvider : ISimilarItemsProvider TItemType item, SimilarItemsQuery query, CancellationToken cancellationToken); + + bool ILocalSimilarItemsProvider.Supports(Type itemType) + => typeof(TItemType).IsAssignableFrom(itemType); + + Task> ILocalSimilarItemsProvider.GetSimilarItemsAsync( + BaseItem item, + SimilarItemsQuery query, + CancellationToken cancellationToken) + => GetSimilarItemsAsync((TItemType)item, query, cancellationToken); } diff --git a/MediaBrowser.Controller/Library/IRemoteSimilarItemsProvider.cs b/MediaBrowser.Controller/Library/IRemoteSimilarItemsProvider.cs index a77b6628d9..3803e51769 100644 --- a/MediaBrowser.Controller/Library/IRemoteSimilarItemsProvider.cs +++ b/MediaBrowser.Controller/Library/IRemoteSimilarItemsProvider.cs @@ -1,15 +1,42 @@ +using System; using System.Collections.Generic; using System.Threading; using MediaBrowser.Controller.Entities; namespace MediaBrowser.Controller.Library; +/// +/// Provides similar item references from remote/external sources. +/// Returns lightweight references with ProviderIds that the manager resolves to library items. +/// +public interface IRemoteSimilarItemsProvider : ISimilarItemsProvider +{ + /// + /// Determines whether the provider can handle items of the specified type. + /// + /// The item type. + /// true if the provider handles this item type; otherwise false. + bool Supports(Type itemType); + + /// + /// Gets similar item references from an external source as an async stream. + /// + /// The source item to find similar items for. + /// The query options (user, limit, exclusions). + /// Cancellation token. + /// An async enumerable of similar item references. + IAsyncEnumerable GetSimilarItemsAsync( + BaseItem item, + SimilarItemsQuery query, + CancellationToken cancellationToken); +} + /// /// Provides similar item references from remote/external sources for a specific item type. /// Returns lightweight references with ProviderIds that the manager resolves to library items. /// /// The type of item this provider handles. -public interface IRemoteSimilarItemsProvider : ISimilarItemsProvider +public interface IRemoteSimilarItemsProvider : IRemoteSimilarItemsProvider where TItemType : BaseItem { /// @@ -23,4 +50,13 @@ public interface IRemoteSimilarItemsProvider : ISimilarItemsProvider TItemType item, SimilarItemsQuery query, CancellationToken cancellationToken); + + bool IRemoteSimilarItemsProvider.Supports(Type itemType) + => typeof(TItemType).IsAssignableFrom(itemType); + + IAsyncEnumerable IRemoteSimilarItemsProvider.GetSimilarItemsAsync( + BaseItem item, + SimilarItemsQuery query, + CancellationToken cancellationToken) + => GetSimilarItemsAsync((TItemType)item, query, cancellationToken); } diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/Api/ListenBrainzLabsClient.cs b/MediaBrowser.Providers/Plugins/ListenBrainz/Api/ListenBrainzLabsClient.cs index e57aa3ed1d..e080370b8c 100644 --- a/MediaBrowser.Providers/Plugins/ListenBrainz/Api/ListenBrainzLabsClient.cs +++ b/MediaBrowser.Providers/Plugins/ListenBrainz/Api/ListenBrainzLabsClient.cs @@ -15,11 +15,11 @@ namespace MediaBrowser.Providers.Plugins.ListenBrainz.Api; /// /// Client for the ListenBrainz Labs API. /// -public class ListenBrainzLabsClient +public class ListenBrainzLabsClient : IDisposable { private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; - private readonly Lock _rateLimitLock = new(); + private readonly SemaphoreSlim _rateLimitLock = new(1, 1); private DateTime _lastRequestTime = DateTime.MinValue; @@ -52,7 +52,7 @@ public class ListenBrainzLabsClient var rateLimit = config?.RateLimit ?? PluginConfiguration.DefaultRateLimit; // Enforce rate limit - EnforceRateLimit(rateLimit); + await EnforceRateLimitAsync(rateLimit, cancellationToken).ConfigureAwait(false); var url = $"{baseUrl}/similar-artists/json?artist_mbids={artistMbid}&algorithm={algorithm}"; @@ -86,19 +86,43 @@ public class ListenBrainzLabsClient } } - private void EnforceRateLimit(double rateLimitSeconds) + /// + public void Dispose() { - lock (_rateLimitLock) + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Releases unmanaged and - optionally - managed resources. + /// + /// true to release both managed and unmanaged resources; false to release only unmanaged resources. + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _rateLimitLock.Dispose(); + } + } + + private async Task EnforceRateLimitAsync(double rateLimitSeconds, CancellationToken cancellationToken) + { + await _rateLimitLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try { var timeSinceLastRequest = DateTime.UtcNow - _lastRequestTime; var requiredDelay = TimeSpan.FromSeconds(rateLimitSeconds) - timeSinceLastRequest; if (requiredDelay > TimeSpan.Zero) { - Thread.Sleep(requiredDelay); + await Task.Delay(requiredDelay, cancellationToken).ConfigureAwait(false); } _lastRequestTime = DateTime.UtcNow; } + finally + { + _rateLimitLock.Release(); + } } } -- cgit v1.2.3