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/ILocalSimilarItemsProvider.cs | 27 ++++++++++++ .../Library/IRemoteSimilarItemsProvider.cs | 26 +++++++++++ .../Library/ISimilarItemsManager.cs | 50 ++++++++++++++++++++++ .../Library/ISimilarItemsProvider.cs | 26 +++++++++++ .../Library/SimilarItemReference.cs | 22 ++++++++++ .../Library/SimilarItemsQuery.cs | 37 ++++++++++++++++ 6 files changed, 188 insertions(+) create mode 100644 MediaBrowser.Controller/Library/ILocalSimilarItemsProvider.cs create mode 100644 MediaBrowser.Controller/Library/IRemoteSimilarItemsProvider.cs create mode 100644 MediaBrowser.Controller/Library/ISimilarItemsManager.cs create mode 100644 MediaBrowser.Controller/Library/ISimilarItemsProvider.cs create mode 100644 MediaBrowser.Controller/Library/SimilarItemReference.cs create mode 100644 MediaBrowser.Controller/Library/SimilarItemsQuery.cs (limited to 'MediaBrowser.Controller/Library') diff --git a/MediaBrowser.Controller/Library/ILocalSimilarItemsProvider.cs b/MediaBrowser.Controller/Library/ILocalSimilarItemsProvider.cs new file mode 100644 index 0000000000..9bf0121f5f --- /dev/null +++ b/MediaBrowser.Controller/Library/ILocalSimilarItemsProvider.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Library; + +/// +/// 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 + where TItemType : BaseItem +{ + /// + /// 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( + TItemType item, + SimilarItemsQuery query, + CancellationToken cancellationToken); +} diff --git a/MediaBrowser.Controller/Library/IRemoteSimilarItemsProvider.cs b/MediaBrowser.Controller/Library/IRemoteSimilarItemsProvider.cs new file mode 100644 index 0000000000..a77b6628d9 --- /dev/null +++ b/MediaBrowser.Controller/Library/IRemoteSimilarItemsProvider.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Threading; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Library; + +/// +/// 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 + where TItemType : BaseItem +{ + /// + /// 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( + TItemType item, + SimilarItemsQuery query, + CancellationToken cancellationToken); +} diff --git a/MediaBrowser.Controller/Library/ISimilarItemsManager.cs b/MediaBrowser.Controller/Library/ISimilarItemsManager.cs new file mode 100644 index 0000000000..0ced6f71ee --- /dev/null +++ b/MediaBrowser.Controller/Library/ISimilarItemsManager.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Configuration; + +namespace MediaBrowser.Controller.Library; + +/// +/// Interface for managing similar items providers and operations. +/// +public interface ISimilarItemsManager +{ + /// + /// Registers similar items providers discovered through dependency injection. + /// + /// The similar items providers to register. + void AddParts(IEnumerable providers); + + /// + /// Gets the similar items providers for a specific item type. + /// + /// The item type. + /// The list of similar items providers for that type. + IReadOnlyList GetSimilarItemsProviders() + where T : BaseItem; + + /// + /// Gets similar items for the specified item. + /// + /// The source item to find similar items for. + /// Artist IDs to exclude from results. + /// The user context. + /// The DTO options. + /// Maximum number of results. + /// The library options for provider configuration. + /// The cancellation token. + /// The list of similar items. + Task> GetSimilarItemsAsync( + BaseItem item, + IReadOnlyList excludeArtistIds, + User? user, + DtoOptions dtoOptions, + int? limit, + LibraryOptions? libraryOptions, + CancellationToken cancellationToken); +} diff --git a/MediaBrowser.Controller/Library/ISimilarItemsProvider.cs b/MediaBrowser.Controller/Library/ISimilarItemsProvider.cs new file mode 100644 index 0000000000..0d089369a8 --- /dev/null +++ b/MediaBrowser.Controller/Library/ISimilarItemsProvider.cs @@ -0,0 +1,26 @@ +using System; +using MediaBrowser.Model.Configuration; + +namespace MediaBrowser.Controller.Library; + +/// +/// Base marker interface for similar items providers. +/// +public interface ISimilarItemsProvider +{ + /// + /// Gets the name of the provider. + /// + string Name { get; } + + /// + /// Gets the type of the provider. + /// + MetadataPluginType Type { get; } + + /// + /// Gets the cache duration for results from this provider. + /// If null, results will not be cached. + /// + TimeSpan? CacheDuration => null; +} diff --git a/MediaBrowser.Controller/Library/SimilarItemReference.cs b/MediaBrowser.Controller/Library/SimilarItemReference.cs new file mode 100644 index 0000000000..2a40c93bdd --- /dev/null +++ b/MediaBrowser.Controller/Library/SimilarItemReference.cs @@ -0,0 +1,22 @@ +namespace MediaBrowser.Controller.Library; + +/// +/// A reference to a similar item by provider ID with a similarity score. +/// +public class SimilarItemReference +{ + /// + /// Gets or sets the provider name (e.g., "Tmdb", "MusicBrainzArtist"). + /// + public required string ProviderName { get; set; } + + /// + /// Gets or sets the provider ID value. + /// + public required string ProviderId { get; set; } + + /// + /// Gets or sets the similarity score (0.0 to 1.0). + /// + public float? Score { get; set; } +} diff --git a/MediaBrowser.Controller/Library/SimilarItemsQuery.cs b/MediaBrowser.Controller/Library/SimilarItemsQuery.cs new file mode 100644 index 0000000000..1ed3ceec16 --- /dev/null +++ b/MediaBrowser.Controller/Library/SimilarItemsQuery.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Dto; + +namespace MediaBrowser.Controller.Library; + +/// +/// Query options for similar items requests. +/// +public class SimilarItemsQuery +{ + /// + /// Gets or sets the user context. + /// + public User? User { get; set; } + + /// + /// Gets or sets the maximum number of results. + /// + public int? Limit { get; set; } + + /// + /// Gets or sets the DTO options. + /// + public DtoOptions? DtoOptions { get; set; } + + /// + /// Gets or sets the item IDs to exclude from results. + /// + public IReadOnlyList ExcludeItemIds { get; set; } = []; + + /// + /// Gets or sets the artist IDs to exclude from results. + /// + public IReadOnlyList ExcludeArtistIds { get; set; } = []; +} -- cgit v1.2.3 From 39049a726e1a88e8acf1d8cc5c217bc8d86be9ae Mon Sep 17 00:00:00 2001 From: TheMelmacian <76712303+TheMelmacian@users.noreply.github.com> Date: Tue, 12 May 2026 01:47:07 +0200 Subject: move language filters from QueryFiltersLegacy to QueryFilters --- .../Library/LibraryManager.cs | 13 +++++++- Jellyfin.Api/Controllers/FilterController.cs | 37 +++++++++++++++++++++- Jellyfin.Api/Controllers/ItemsController.cs | 2 +- .../Item/BaseItemRepository.Querying.cs | 36 +-------------------- .../Item/BaseItemRepository.TranslateQuery.cs | 1 + .../Item/MediaStreamRepository.cs | 11 +++++++ MediaBrowser.Controller/Library/ILibraryManager.cs | 7 ++++ .../Persistence/IMediaStreamRepository.cs | 7 ++++ MediaBrowser.Model/Querying/QueryFilters.cs | 6 ++++ MediaBrowser.Model/Querying/QueryFiltersLegacy.cs | 6 ---- 10 files changed, 82 insertions(+), 44 deletions(-) (limited to 'MediaBrowser.Controller/Library') diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 11f1496086..15d51cf35f 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -87,6 +87,7 @@ namespace Emby.Server.Implementations.Library private readonly IPathManager _pathManager; private readonly FastConcurrentLru _cache; private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule; + private readonly IMediaStreamRepository _mediaStreamRepository; /// /// The _root folder sync lock. @@ -129,6 +130,7 @@ namespace Emby.Server.Implementations.Library /// The people repository. /// The path manager. /// The .ignore rule handler. + /// The media stream repository. public LibraryManager( IServerApplicationHost appHost, ILoggerFactory loggerFactory, @@ -151,7 +153,8 @@ namespace Emby.Server.Implementations.Library IDirectoryService directoryService, IPeopleRepository peopleRepository, IPathManager pathManager, - DotIgnoreIgnoreRule dotIgnoreIgnoreRule) + DotIgnoreIgnoreRule dotIgnoreIgnoreRule, + IMediaStreamRepository mediaStreamRepository) { _appHost = appHost; _logger = loggerFactory.CreateLogger(); @@ -181,6 +184,8 @@ namespace Emby.Server.Implementations.Library _configurationManager.ConfigurationUpdated += ConfigurationUpdated; + _mediaStreamRepository = mediaStreamRepository; + RecordConfigurationValues(_configurationManager.Configuration); } @@ -3800,5 +3805,11 @@ namespace Emby.Server.Implementations.Library SetTopParentOrAncestorIds(query); return _itemRepository.GetQueryFiltersLegacy(query); } + + /// + public IReadOnlyList GetMediaStreamLanguages(MediaStreamType mediaStreamType) + { + return _mediaStreamRepository.GetMediaStreamLanguages(mediaStreamType); + } } } diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs index 2f53784db1..740423ef04 100644 --- a/Jellyfin.Api/Controllers/FilterController.cs +++ b/Jellyfin.Api/Controllers/FilterController.cs @@ -8,6 +8,8 @@ using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -24,16 +26,19 @@ public class FilterController : BaseJellyfinApiController { private readonly ILibraryManager _libraryManager; private readonly IUserManager _userManager; + private readonly ILocalizationManager _localization; /// /// Initializes a new instance of the class. /// /// Instance of the interface. /// Instance of the interface. - public FilterController(ILibraryManager libraryManager, IUserManager userManager) + /// Instance of the interface. + public FilterController(ILibraryManager libraryManager, IUserManager userManager, ILocalizationManager localization) { _libraryManager = libraryManager; _userManager = userManager; + _localization = localization; } /// @@ -183,6 +188,36 @@ public class FilterController : BaseJellyfinApiController }).ToArray(); } + if (includeItemTypes.Contains(BaseItemKind.Movie) || includeItemTypes.Contains(BaseItemKind.Series)) + { + filters.AudioLanguages = _libraryManager + .GetMediaStreamLanguages(MediaStreamType.Audio) + .Select(language => + { + var culture = _localization.FindLanguageInfo(language); + return new NameValuePair + { + Name = culture != null ? $"{culture.DisplayName} ({language})" : language, + Value = language + }; + }) + .OrderBy(l => l.Name) + .ToArray(); + filters.SubtitleLanguages = _libraryManager + .GetMediaStreamLanguages(MediaStreamType.Subtitle) + .Select(language => + { + var culture = _localization.FindLanguageInfo(language); + return new NameValuePair + { + Name = culture != null ? $"{culture.DisplayName} ({language})" : language, + Value = language + }; + }) + .OrderBy(l => l.Name) + .ToArray(); + } + return filters; } } diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index a813109c96..8eca2787b1 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -421,7 +421,7 @@ public class ItemsController : BaseJellyfinApiController } else { - // if we want to know if an item has no subtitles we don't need to check for subtitles of a specific language + // if we search for items without subtitles, we don't need to check for subtitles of a specific language query.SubtitleLanguages = []; } } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs index 71b46b3cb5..dc16c3b1b3 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs @@ -535,46 +535,12 @@ public sealed partial class BaseItemRepository .OrderBy(g => g) .ToArray(); - // At the moment language filters are only available for video types (Movie and Series libraries). - // They are fetched directly from the MediaStreamInfos table and only filtered by StreamType. - // This is the fastest and most perfomant way to get the list of available languages, - // but the filter values can include language tags that are not linked to any item in the current library. - var subtitleLanguages = IncludesVideoTypes(filter) - ? context.MediaStreamInfos - .Where(s => s.StreamType == MediaStreamTypeEntity.Subtitle) - .Select(s => string.IsNullOrEmpty(s.Language) ? "und" : s.Language) // und = undetermined - .Distinct() - .OrderBy(l => l) - .ToArray() - : []; - - var audioLanguages = IncludesVideoTypes(filter) - ? context.MediaStreamInfos - .Where(s => s.StreamType == MediaStreamTypeEntity.Audio) - .Select(s => string.IsNullOrEmpty(s.Language) ? "und" : s.Language) // und = undetermined - .Distinct() - .OrderBy(l => l) - .ToArray() - : []; - return new QueryFiltersLegacy { Years = years, OfficialRatings = officialRatings, Tags = tags, - Genres = genres, - SubtitleLanguages = subtitleLanguages, - AudioLanguages = audioLanguages + Genres = genres }; } - - private bool IncludesVideoTypes(InternalItemsQuery filter) - { - return filter.IncludeItemTypes.Contains(BaseItemKind.Movie) - || filter.IncludeItemTypes.Contains(BaseItemKind.Video) - || filter.IncludeItemTypes.Contains(BaseItemKind.Series) - || filter.IncludeItemTypes.Contains(BaseItemKind.Season) - || filter.IncludeItemTypes.Contains(BaseItemKind.Episode) - || filter.IncludeItemTypes.Contains(BaseItemKind.Trailer); - } } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index b58e7fffe3..3d1aafd72e 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -1078,6 +1078,7 @@ public sealed partial class BaseItemRepository { // Dvds and Blu-rays can either be stored in a folder structure or as an iso file // => to find all matches we need to check both: VideoType and IsoType + // alternatively, we could provide specific IsoType filters var videoTypeBs = filter.VideoTypes.Select(vt => $"\"VideoType\":\"{vt}\"").ToArray(); var isoTypeBs = filter.VideoTypes.Select(vt => $"\"IsoType\":\"{vt}\"").ToArray(); Expression> hasVideoType = e => videoTypeBs.Any(f => e.Data!.Contains(f)) || isoTypeBs.Any(f => e.Data!.Contains(f)); diff --git a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs index dd0446f49a..7fa33c8639 100644 --- a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs +++ b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs @@ -55,6 +55,17 @@ public class MediaStreamRepository : IMediaStreamRepository return TranslateQuery(context.MediaStreamInfos.AsNoTracking(), filter).AsEnumerable().Select(Map).ToArray(); } + /// + public IReadOnlyList GetMediaStreamLanguages(MediaStreamType mediaStreamType) + { + using var context = _dbProvider.CreateDbContext(); + return context.MediaStreamInfos + .Where(e => e.StreamType == (MediaStreamTypeEntity)mediaStreamType) + .Select(s => string.IsNullOrEmpty(s.Language) ? "und" : s.Language) // und = undetermined + .Distinct() + .ToArray(); + } + private string? GetPathToSave(string? path) { if (path is null) diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index f5e3d7034e..f4c2196400 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -784,5 +784,12 @@ namespace MediaBrowser.Controller.Library /// The query filter. /// Aggregated filter values. QueryFiltersLegacy GetQueryFiltersLegacy(InternalItemsQuery query); + + /// + /// Gets a list of all language codes of the provided stream type. + /// + /// The stream type. + /// List of language codes. + IReadOnlyList GetMediaStreamLanguages(MediaStreamType mediaStreamType); } } diff --git a/MediaBrowser.Controller/Persistence/IMediaStreamRepository.cs b/MediaBrowser.Controller/Persistence/IMediaStreamRepository.cs index 665129eafd..de04ff021d 100644 --- a/MediaBrowser.Controller/Persistence/IMediaStreamRepository.cs +++ b/MediaBrowser.Controller/Persistence/IMediaStreamRepository.cs @@ -21,6 +21,13 @@ public interface IMediaStreamRepository /// IEnumerable{MediaStream}. IReadOnlyList GetMediaStreams(MediaStreamQuery filter); + /// + /// Gets all language codes of the provided stream type. + /// + /// The type of the media stream. + /// IEnumerable{string}. + IReadOnlyList GetMediaStreamLanguages(MediaStreamType mediaStreamType); + /// /// Saves the media streams. /// diff --git a/MediaBrowser.Model/Querying/QueryFilters.cs b/MediaBrowser.Model/Querying/QueryFilters.cs index 73b27a7b06..b877af71c6 100644 --- a/MediaBrowser.Model/Querying/QueryFilters.cs +++ b/MediaBrowser.Model/Querying/QueryFilters.cs @@ -12,10 +12,16 @@ namespace MediaBrowser.Model.Querying { Tags = Array.Empty(); Genres = Array.Empty(); + AudioLanguages = Array.Empty(); + SubtitleLanguages = Array.Empty(); } public NameGuidPair[] Genres { get; set; } public string[] Tags { get; set; } + + public NameValuePair[] AudioLanguages { get; set; } + + public NameValuePair[] SubtitleLanguages { get; set; } } } diff --git a/MediaBrowser.Model/Querying/QueryFiltersLegacy.cs b/MediaBrowser.Model/Querying/QueryFiltersLegacy.cs index aa1ca85cad..fcb450ed30 100644 --- a/MediaBrowser.Model/Querying/QueryFiltersLegacy.cs +++ b/MediaBrowser.Model/Querying/QueryFiltersLegacy.cs @@ -13,8 +13,6 @@ namespace MediaBrowser.Model.Querying Tags = Array.Empty(); OfficialRatings = Array.Empty(); Years = Array.Empty(); - AudioLanguages = Array.Empty(); - SubtitleLanguages = Array.Empty(); } public string[] Genres { get; set; } @@ -24,9 +22,5 @@ namespace MediaBrowser.Model.Querying public string[] OfficialRatings { get; set; } public int[] Years { get; set; } - - public string[] AudioLanguages { get; set; } - - public string[] SubtitleLanguages { get; set; } } } -- 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 'MediaBrowser.Controller/Library') 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