diff options
| author | Bond-009 <bond.009@outlook.com> | 2026-05-15 10:00:53 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-05-15 10:00:53 +0200 |
| commit | 8e602f982f79201cc316f64baf018a5a536d38f6 (patch) | |
| tree | 94af198766ed00d736b31918b709e366f226845b /Emby.Server.Implementations | |
| parent | 097097300a473c7abe9d578169f60c39174345f1 (diff) | |
| parent | 0b209fe66b33bfeed1b1259efba31ceb643c4e0e (diff) | |
Merge pull request #15970 from Shadowghost/similarity
Implement similarity providers
Diffstat (limited to 'Emby.Server.Implementations')
8 files changed, 824 insertions, 0 deletions
diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 3e98a5276c..b624a6d4f9 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -25,6 +25,7 @@ using Emby.Server.Implementations.Dto; using Emby.Server.Implementations.HttpServer.Security; using Emby.Server.Implementations.IO; using Emby.Server.Implementations.Library; +using Emby.Server.Implementations.Library.SimilarItems; using Emby.Server.Implementations.Localization; using Emby.Server.Implementations.Playlists; using Emby.Server.Implementations.Plugins; @@ -92,7 +93,11 @@ using MediaBrowser.Model.System; using MediaBrowser.Model.Tasks; using MediaBrowser.Providers.Lyric; using MediaBrowser.Providers.Manager; +using MediaBrowser.Providers.Plugins.ListenBrainz; +using MediaBrowser.Providers.Plugins.ListenBrainz.Api; using MediaBrowser.Providers.Plugins.Tmdb; +using MediaBrowser.Providers.Plugins.Tmdb.Movies; +using MediaBrowser.Providers.Plugins.Tmdb.TV; using MediaBrowser.Providers.Subtitles; using MediaBrowser.XbmcMetadata.Providers; using Microsoft.AspNetCore.Http; @@ -483,6 +488,11 @@ namespace Emby.Server.Implementations serviceCollection.AddScoped<ISystemManager, SystemManager>(); serviceCollection.AddSingleton<TmdbClientManager>(); + serviceCollection.AddSingleton<TmdbMovieSimilarProvider>(); + serviceCollection.AddSingleton<TmdbSeriesSimilarProvider>(); + + serviceCollection.AddSingleton<ListenBrainzLabsClient>(); + serviceCollection.AddSingleton<ListenBrainzSimilarArtistProvider>(); serviceCollection.AddSingleton(NetManager); @@ -536,6 +546,8 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton<ILibraryMonitor, LibraryMonitor>(); serviceCollection.AddSingleton<DotIgnoreIgnoreRule>(); + serviceCollection.AddSingleton<ISimilarItemsManager, SimilarItemsManager>(); + serviceCollection.AddSingleton<ISearchEngine, SearchEngine>(); serviceCollection.AddSingleton<IWebSocketManager, WebSocketManager>(); @@ -693,6 +705,8 @@ namespace Emby.Server.Implementations GetExports<IExternalUrlProvider>()); Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>()); + + Resolve<ISimilarItemsManager>().AddParts(GetExports<ISimilarItemsProvider>()); } /// <summary> diff --git a/Emby.Server.Implementations/Library/SimilarItems/AudioSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/AudioSimilarItemsProvider.cs new file mode 100644 index 0000000000..1cc670b8ee --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/AudioSimilarItemsProvider.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Provides similar items for audio tracks. +/// </summary> +public class AudioSimilarItemsProvider : ILocalSimilarItemsProvider<Audio> +{ + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="AudioSimilarItemsProvider"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + public AudioSimilarItemsProvider(ILibraryManager libraryManager) + { + _libraryManager = libraryManager; + } + + /// <inheritdoc/> + public string Name => "Local Genre/Tag"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider; + + /// <inheritdoc/> + public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(Audio item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + var internalQuery = new InternalItemsQuery(query.User) + { + Genres = item.Genres, + Tags = item.Tags, + Limit = query.Limit, + DtoOptions = query.DtoOptions ?? new DtoOptions(), + ExcludeItemIds = [.. query.ExcludeItemIds], + ExcludeArtistIds = [.. query.ExcludeArtistIds], + IncludeItemTypes = [BaseItemKind.Audio], + EnableGroupByMetadataKey = false, + EnableTotalRecordCount = true, + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] + }; + + return Task.FromResult(_libraryManager.GetItemList(internalQuery)); + } +} diff --git a/Emby.Server.Implementations/Library/SimilarItems/LiveTvProgramSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/LiveTvProgramSimilarItemsProvider.cs new file mode 100644 index 0000000000..7665ee2f79 --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/LiveTvProgramSimilarItemsProvider.cs @@ -0,0 +1,94 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Configuration; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Provides similar items for Live TV programs. +/// </summary> +public class LiveTvProgramSimilarItemsProvider : ILocalSimilarItemsProvider<LiveTvProgram> +{ + private readonly ILibraryManager _libraryManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="LiveTvProgramSimilarItemsProvider"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + /// <param name="serverConfigurationManager">The server configuration manager.</param> + public LiveTvProgramSimilarItemsProvider( + ILibraryManager libraryManager, + IServerConfigurationManager serverConfigurationManager) + { + _libraryManager = libraryManager; + _serverConfigurationManager = serverConfigurationManager; + } + + /// <inheritdoc/> + public string Name => "Local Genre/Tag"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider; + + /// <inheritdoc/> + public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(LiveTvProgram item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + BaseItemKind[] includeItemTypes; + bool enableGroupByMetadataKey; + bool enableTotalRecordCount; + + if (item.IsMovie) + { + // Movie-like program + var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; + + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + { + itemTypes.Add(BaseItemKind.Trailer); + itemTypes.Add(BaseItemKind.LiveTvProgram); + } + + includeItemTypes = [.. itemTypes]; + enableGroupByMetadataKey = true; + enableTotalRecordCount = false; + } + else if (item.IsSeries) + { + // Series-like program + includeItemTypes = [BaseItemKind.Series]; + enableGroupByMetadataKey = false; + enableTotalRecordCount = true; + } + else + { + // Default - match same type + includeItemTypes = [item.GetBaseItemKind()]; + enableGroupByMetadataKey = false; + enableTotalRecordCount = true; + } + + var internalQuery = new InternalItemsQuery(query.User) + { + Genres = item.Genres, + Tags = item.Tags, + Limit = query.Limit, + DtoOptions = query.DtoOptions ?? new DtoOptions(), + ExcludeItemIds = [.. query.ExcludeItemIds], + IncludeItemTypes = includeItemTypes, + EnableGroupByMetadataKey = enableGroupByMetadataKey, + EnableTotalRecordCount = enableTotalRecordCount, + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] + }; + + return Task.FromResult(_libraryManager.GetItemList(internalQuery)); + } +} diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs new file mode 100644 index 0000000000..93aa0574c0 --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Provides similar items for movies and trailers. +/// </summary> +public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie>, ILocalSimilarItemsProvider<Trailer> +{ + private readonly ILibraryManager _libraryManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="MovieSimilarItemsProvider"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + /// <param name="serverConfigurationManager">The server configuration manager.</param> + public MovieSimilarItemsProvider( + ILibraryManager libraryManager, + IServerConfigurationManager serverConfigurationManager) + { + _libraryManager = libraryManager; + _serverConfigurationManager = serverConfigurationManager; + } + + /// <inheritdoc/> + public string Name => "Local Genre/Tag"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider; + + /// <inheritdoc/> + public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(Movie item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + return Task.FromResult(GetSimilarMovieItems(item, query)); + } + + /// <inheritdoc/> + public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(Trailer item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + return Task.FromResult(GetSimilarMovieItems(item, query)); + } + + bool ILocalSimilarItemsProvider.Supports(Type itemType) + => typeof(Movie).IsAssignableFrom(itemType) || typeof(Trailer).IsAssignableFrom(itemType); + + Task<IReadOnlyList<BaseItem>> 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<BaseItem> GetSimilarMovieItems(BaseItem item, SimilarItemsQuery query) + { + var includeItemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; + + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + { + includeItemTypes.Add(BaseItemKind.Trailer); + includeItemTypes.Add(BaseItemKind.LiveTvProgram); + } + + var internalQuery = new InternalItemsQuery(query.User) + { + Genres = item.Genres, + Tags = item.Tags, + Limit = query.Limit, + DtoOptions = query.DtoOptions ?? new DtoOptions(), + ExcludeItemIds = [.. query.ExcludeItemIds], + IncludeItemTypes = [.. includeItemTypes], + EnableGroupByMetadataKey = true, + EnableTotalRecordCount = false, + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] + }; + + return _libraryManager.GetItemList(internalQuery); + } +} diff --git a/Emby.Server.Implementations/Library/SimilarItems/MusicAlbumSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MusicAlbumSimilarItemsProvider.cs new file mode 100644 index 0000000000..c13045deda --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/MusicAlbumSimilarItemsProvider.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Provides similar items for music albums. +/// </summary> +public class MusicAlbumSimilarItemsProvider : ILocalSimilarItemsProvider<MusicAlbum> +{ + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="MusicAlbumSimilarItemsProvider"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + public MusicAlbumSimilarItemsProvider(ILibraryManager libraryManager) + { + _libraryManager = libraryManager; + } + + /// <inheritdoc/> + public string Name => "Local Genre/Tag"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider; + + /// <inheritdoc/> + public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(MusicAlbum item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + var internalQuery = new InternalItemsQuery(query.User) + { + Genres = item.Genres, + Tags = item.Tags, + Limit = query.Limit, + DtoOptions = query.DtoOptions ?? new DtoOptions(), + ExcludeItemIds = [.. query.ExcludeItemIds], + ExcludeArtistIds = [.. query.ExcludeArtistIds], + IncludeItemTypes = [BaseItemKind.MusicAlbum], + EnableGroupByMetadataKey = false, + EnableTotalRecordCount = true, + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] + }; + + return Task.FromResult(_libraryManager.GetItemList(internalQuery)); + } +} diff --git a/Emby.Server.Implementations/Library/SimilarItems/MusicArtistSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MusicArtistSimilarItemsProvider.cs new file mode 100644 index 0000000000..3331419442 --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/MusicArtistSimilarItemsProvider.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Provides similar items for music artists. +/// </summary> +public class MusicArtistSimilarItemsProvider : ILocalSimilarItemsProvider<MusicArtist> +{ + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="MusicArtistSimilarItemsProvider"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + public MusicArtistSimilarItemsProvider(ILibraryManager libraryManager) + { + _libraryManager = libraryManager; + } + + /// <inheritdoc/> + public string Name => "Local Genre/Tag"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider; + + /// <inheritdoc/> + public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(MusicArtist item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + var internalQuery = new InternalItemsQuery(query.User) + { + Genres = item.Genres, + Tags = item.Tags, + Limit = query.Limit, + DtoOptions = query.DtoOptions ?? new DtoOptions(), + ExcludeItemIds = [.. query.ExcludeItemIds], + ExcludeArtistIds = [.. query.ExcludeArtistIds], + IncludeItemTypes = [BaseItemKind.MusicArtist], + EnableGroupByMetadataKey = false, + EnableTotalRecordCount = true, + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] + }; + + return Task.FromResult(_libraryManager.GetItemList(internalQuery)); + } +} diff --git a/Emby.Server.Implementations/Library/SimilarItems/SeriesSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/SeriesSimilarItemsProvider.cs new file mode 100644 index 0000000000..0366fb752e --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/SeriesSimilarItemsProvider.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Provides similar items for TV series. +/// </summary> +public class SeriesSimilarItemsProvider : ILocalSimilarItemsProvider<Series> +{ + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="SeriesSimilarItemsProvider"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + public SeriesSimilarItemsProvider(ILibraryManager libraryManager) + { + _libraryManager = libraryManager; + } + + /// <inheritdoc/> + public string Name => "Local Genre/Tag"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider; + + /// <inheritdoc/> + public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(Series item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + var internalQuery = new InternalItemsQuery(query.User) + { + Genres = item.Genres, + Tags = item.Tags, + Limit = query.Limit, + DtoOptions = query.DtoOptions ?? new DtoOptions(), + ExcludeItemIds = [.. query.ExcludeItemIds], + IncludeItemTypes = [BaseItemKind.Series], + EnableGroupByMetadataKey = false, + EnableTotalRecordCount = true, + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] + }; + + return Task.FromResult(_libraryManager.GetItemList(internalQuery)); + } +} diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs new file mode 100644 index 0000000000..b56779cf3f --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs @@ -0,0 +1,406 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +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; + +/// <summary> +/// Manages similar items providers and orchestrates similar items operations. +/// </summary> +public class SimilarItemsManager : ISimilarItemsManager +{ + private readonly ILogger<SimilarItemsManager> _logger; + private readonly IServerApplicationPaths _appPaths; + private readonly ILibraryManager _libraryManager; + private readonly IFileSystem _fileSystem; + private ISimilarItemsProvider[] _similarItemsProviders = []; + + /// <summary> + /// Initializes a new instance of the <see cref="SimilarItemsManager"/> class. + /// </summary> + /// <param name="logger">The logger.</param> + /// <param name="appPaths">The server application paths.</param> + /// <param name="libraryManager">The library manager.</param> + /// <param name="fileSystem">The file system.</param> + public SimilarItemsManager( + ILogger<SimilarItemsManager> logger, + IServerApplicationPaths appPaths, + ILibraryManager libraryManager, + IFileSystem fileSystem) + { + _logger = logger; + _appPaths = appPaths; + _libraryManager = libraryManager; + _fileSystem = fileSystem; + } + + /// <inheritdoc/> + public void AddParts(IEnumerable<ISimilarItemsProvider> providers) + { + _similarItemsProviders = providers.ToArray(); + } + + /// <inheritdoc/> + public IReadOnlyList<ISimilarItemsProvider> GetSimilarItemsProviders<T>() + where T : BaseItem + { + var itemType = typeof(T); + return _similarItemsProviders + .Where(p => (p is ILocalSimilarItemsProvider local && local.Supports(itemType)) + || (p is IRemoteSimilarItemsProvider remote && remote.Supports(itemType))) + .ToList(); + } + + /// <inheritdoc/> + public async Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync( + BaseItem item, + IReadOnlyList<Guid> excludeArtistIds, + User? user, + DtoOptions dtoOptions, + int? limit, + LibraryOptions? libraryOptions, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(item); + ArgumentNullException.ThrowIfNull(excludeArtistIds); + + var itemType = item.GetType(); + 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<ILocalSimilarItemsProvider>() + .Where(p => p.Supports(itemType)) + .ToList(); + var remoteProviders = _similarItemsProviders + .OfType<IRemoteSimilarItemsProvider>() + .Where(p => p.Supports(itemType)); + var matchingProviders = new List<ISimilarItemsProvider>(localProviders); + + var typeOptions = libraryOptions?.GetTypeOptions(itemType.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<Guid> { 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, itemType.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<SimilarItemReference>(); + var pendingBatch = new List<SimilarItemReference>(); + + 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<SimilarItemReference> references, + int providerOrder, + User? user, + DtoOptions dtoOptions, + BaseItemKind itemKind, + HashSet<Guid> excludeIds) + { + if (references.Count == 0) + { + return []; + } + + var resolvedById = new Dictionary<Guid, (BaseItem Item, float Score)>(); + 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<List<SimilarItemReference>?> 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<SimilarItemsCache>(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<SimilarItemReference> 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<SimilarItemReference>? 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)); + } +} |
