aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorShadowghost <Ghost_of_Stone@web.de>2026-05-03 23:43:01 +0200
committerShadowghost <Ghost_of_Stone@web.de>2026-05-03 23:43:01 +0200
commit4ebce3907062ade1937440628eebd665440b338d (patch)
tree23b94df3c1fc76de39345f1480d0198b6d7f0ac1
parent622947e37425f3620432995cde5d4a0809d91694 (diff)
Implement Similarity providers
-rw-r--r--Emby.Server.Implementations/ApplicationHost.cs14
-rw-r--r--Emby.Server.Implementations/Library/SimilarItems/AudioSimilarItemsProvider.cs55
-rw-r--r--Emby.Server.Implementations/Library/SimilarItems/LiveTvProgramSimilarItemsProvider.cs94
-rw-r--r--Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs79
-rw-r--r--Emby.Server.Implementations/Library/SimilarItems/MusicAlbumSimilarItemsProvider.cs55
-rw-r--r--Emby.Server.Implementations/Library/SimilarItems/MusicArtistSimilarItemsProvider.cs55
-rw-r--r--Emby.Server.Implementations/Library/SimilarItems/SeriesSimilarItemsProvider.cs54
-rw-r--r--Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs423
-rw-r--r--Jellyfin.Api/Controllers/LibraryController.cs78
-rw-r--r--Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs14
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs11
-rw-r--r--MediaBrowser.Controller/Entities/InternalItemsQuery.cs2
-rw-r--r--MediaBrowser.Controller/Library/ILocalSimilarItemsProvider.cs27
-rw-r--r--MediaBrowser.Controller/Library/IRemoteSimilarItemsProvider.cs26
-rw-r--r--MediaBrowser.Controller/Library/ISimilarItemsManager.cs50
-rw-r--r--MediaBrowser.Controller/Library/ISimilarItemsProvider.cs26
-rw-r--r--MediaBrowser.Controller/Library/SimilarItemReference.cs22
-rw-r--r--MediaBrowser.Controller/Library/SimilarItemsQuery.cs37
-rw-r--r--MediaBrowser.Controller/Providers/IProviderManager.cs11
-rw-r--r--MediaBrowser.Model/Configuration/MetadataPluginType.cs4
-rw-r--r--MediaBrowser.Model/Configuration/TypeOptions.cs16
-rw-r--r--MediaBrowser.Providers/Manager/ProviderManager.cs17
-rw-r--r--MediaBrowser.Providers/MediaBrowser.Providers.csproj2
-rw-r--r--MediaBrowser.Providers/Plugins/ListenBrainz/Api/ListenBrainzLabsClient.cs104
-rw-r--r--MediaBrowser.Providers/Plugins/ListenBrainz/Api/Models/SimilarArtistData.cs28
-rw-r--r--MediaBrowser.Providers/Plugins/ListenBrainz/Api/Models/SimilarArtistsResponse.cs16
-rw-r--r--MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/PluginConfiguration.cs60
-rw-r--r--MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/SimilarityAlgorithm.cs37
-rw-r--r--MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/SimilarityAlgorithmExtensions.cs23
-rw-r--r--MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html87
-rw-r--r--MediaBrowser.Providers/Plugins/ListenBrainz/ListenBrainzPlugin.cs53
-rw-r--r--MediaBrowser.Providers/Plugins/ListenBrainz/ListenBrainzSimilarArtistProvider.cs82
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs89
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs89
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs48
-rw-r--r--tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs3
36 files changed, 1830 insertions, 61 deletions
diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs
index e8cab6ea8c..a2d94b193a 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;
@@ -485,6 +490,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);
@@ -537,6 +547,8 @@ namespace Emby.Server.Implementations
serviceCollection.AddSingleton<ILibraryMonitor, LibraryMonitor>();
+ serviceCollection.AddSingleton<ISimilarItemsManager, SimilarItemsManager>();
+
serviceCollection.AddSingleton<ISearchEngine, SearchEngine>();
serviceCollection.AddSingleton<IWebSocketManager, WebSocketManager>();
@@ -694,6 +706,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..f9547c2c38
--- /dev/null
+++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs
@@ -0,0 +1,79 @@
+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 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));
+ }
+
+ 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..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;
+
+/// <summary>
+/// Manages similar items providers and orchestrates similar items operations.
+/// </summary>
+public class SimilarItemsManager : ISimilarItemsManager
+{
+ private static readonly ConcurrentDictionary<Type, MethodInfo> _genericMethodCache = new();
+ private static readonly MethodInfo _getSimilarItemsInternalMethod = typeof(SimilarItemsManager)
+ .GetMethod(nameof(GetSimilarItemsInternalAsync), BindingFlags.NonPublic | BindingFlags.Instance)!;
+
+ 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
+ {
+ return _similarItemsProviders
+ .OfType<ILocalSimilarItemsProvider<T>>()
+ .Cast<ISimilarItemsProvider>()
+ .Concat(_similarItemsProviders.OfType<IRemoteSimilarItemsProvider<T>>())
+ .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 method = _genericMethodCache.GetOrAdd(itemType, static type => _getSimilarItemsInternalMethod.MakeGenericMethod(type));
+
+ var task = (Task<IReadOnlyList<BaseItem>>)method.Invoke(this, [item, excludeArtistIds, user, dtoOptions, limit, libraryOptions, cancellationToken])!;
+ return await task.ConfigureAwait(false);
+ }
+
+ private async Task<IReadOnlyList<BaseItem>> GetSimilarItemsInternalAsync<T>(
+ T item,
+ IReadOnlyList<Guid> 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<ILocalSimilarItemsProvider<T>>().Cast<ISimilarItemsProvider>().ToList();
+ var remoteProviders = _similarItemsProviders.OfType<IRemoteSimilarItemsProvider<T>>().Cast<ISimilarItemsProvider>();
+ var matchingProviders = new List<ISimilarItemsProvider>(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<Guid> { item.Id };
+ foreach (var (providerOrder, provider) in orderedProviders.Index())
+ {
+ if (allResults.Count >= requestedLimit || cancellationToken.IsCancellationRequested)
+ {
+ break;
+ }
+
+ try
+ {
+ if (provider is ILocalSimilarItemsProvider<T> 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<T> 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<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));
+ }
+}
diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs
index 69c17f2486..7c2db5cbb5 100644
--- a/Jellyfin.Api/Controllers/LibraryController.cs
+++ b/Jellyfin.Api/Controllers/LibraryController.cs
@@ -47,6 +47,7 @@ namespace Jellyfin.Api.Controllers;
public class LibraryController : BaseJellyfinApiController
{
private readonly IProviderManager _providerManager;
+ private readonly ISimilarItemsManager _similarItemsManager;
private readonly ILibraryManager _libraryManager;
private readonly IUserManager _userManager;
private readonly IDtoService _dtoService;
@@ -60,6 +61,7 @@ public class LibraryController : BaseJellyfinApiController
/// Initializes a new instance of the <see cref="LibraryController"/> class.
/// </summary>
/// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="similarItemsManager">Instance of the <see cref="ISimilarItemsManager"/> interface.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
/// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
@@ -70,6 +72,7 @@ public class LibraryController : BaseJellyfinApiController
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
public LibraryController(
IProviderManager providerManager,
+ ISimilarItemsManager similarItemsManager,
ILibraryManager libraryManager,
IUserManager userManager,
IDtoService dtoService,
@@ -80,6 +83,7 @@ public class LibraryController : BaseJellyfinApiController
IServerConfigurationManager serverConfigurationManager)
{
_providerManager = providerManager;
+ _similarItemsManager = similarItemsManager;
_libraryManager = libraryManager;
_userManager = userManager;
_dtoService = dtoService;
@@ -708,6 +712,7 @@ public class LibraryController : BaseJellyfinApiController
/// <param name="userId">Optional. Filter by user id, and attach user data.</param>
/// <param name="limit">Optional. The maximum number of records to return.</param>
/// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
/// <response code="200">Similar items returned.</response>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> containing the similar items.</returns>
[HttpGet("Artists/{itemId}/Similar", Name = "GetSimilarArtists")]
@@ -718,12 +723,13 @@ public class LibraryController : BaseJellyfinApiController
[HttpGet("Trailers/{itemId}/Similar", Name = "GetSimilarTrailers")]
[Authorize]
[ProducesResponseType(StatusCodes.Status200OK)]
- public ActionResult<QueryResult<BaseItemDto>> GetSimilarItems(
+ public async Task<ActionResult<QueryResult<BaseItemDto>>> GetSimilarItems(
[FromRoute, Required] Guid itemId,
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] excludeArtistIds,
[FromQuery] Guid? userId,
[FromQuery] int? limit,
- [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields)
+ [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
+ CancellationToken cancellationToken)
{
userId = RequestHelpers.GetUserId(User, userId);
var user = userId.IsNullOrEmpty()
@@ -746,57 +752,22 @@ public class LibraryController : BaseJellyfinApiController
var dtoOptions = new DtoOptions { Fields = fields };
- var program = item as IHasProgramAttributes;
- bool? isMovie = item is Movie || (program is not null && program.IsMovie) || item is Trailer;
- bool? isSeries = item is Series || (program is not null && program.IsSeries);
+ // Get library options for provider configuration
+ var libraryOptions = _libraryManager.GetLibraryOptions(item);
- var includeItemTypes = new List<BaseItemKind>();
- if (isMovie.Value)
- {
- includeItemTypes.Add(BaseItemKind.Movie);
- if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
- {
- includeItemTypes.Add(BaseItemKind.Trailer);
- includeItemTypes.Add(BaseItemKind.LiveTvProgram);
- }
- }
- else if (isSeries.Value)
- {
- includeItemTypes.Add(BaseItemKind.Series);
- }
- else
- {
- // For non series and movie types these columns are typically null
- // isSeries = null;
- isMovie = null;
- includeItemTypes.Add(item.GetBaseItemKind());
- }
-
- var query = new InternalItemsQuery(user)
- {
- Genres = item.Genres,
- Tags = item.Tags,
- Limit = limit,
- IncludeItemTypes = includeItemTypes.ToArray(),
- DtoOptions = dtoOptions,
- EnableTotalRecordCount = !isMovie ?? true,
- EnableGroupByMetadataKey = isMovie ?? false,
- ExcludeItemIds = [itemId],
- OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)]
- };
-
- // ExcludeArtistIds
- if (excludeArtistIds.Length != 0)
- {
- query.ExcludeArtistIds = excludeArtistIds;
- }
-
- var itemsResult = _libraryManager.GetItemList(query);
+ var itemsResult = await _similarItemsManager.GetSimilarItemsAsync(
+ item,
+ excludeArtistIds,
+ user,
+ dtoOptions,
+ limit,
+ libraryOptions,
+ cancellationToken).ConfigureAwait(false);
var returnList = _dtoService.GetBaseItemDtos(itemsResult, dtoOptions, user);
return new QueryResult<BaseItemDto>(
- query.StartIndex,
+ 0,
itemsResult.Count,
returnList);
}
@@ -907,6 +878,17 @@ public class LibraryController : BaseJellyfinApiController
.DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
.ToArray(),
+ SimilarItemProviders = plugins
+ .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase))
+ .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.LocalSimilarityProvider || p.Type == MetadataPluginType.SimilarityProvider))
+ .Select(i => new LibraryOptionInfoDto
+ {
+ Name = i.Name,
+ DefaultEnabled = i.Type == MetadataPluginType.LocalSimilarityProvider
+ })
+ .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
+ .ToArray(),
+
SupportedImageTypes = plugins
.Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase))
.SelectMany(i => i.SupportedImageTypes ?? Array.Empty<ImageType>())
diff --git a/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs b/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs
index f76c4a9678..98da6c8f44 100644
--- a/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs
+++ b/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs
@@ -1,4 +1,3 @@
-using System;
using System.Collections.Generic;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Entities;
@@ -18,20 +17,25 @@ public class LibraryTypeOptionsDto
/// <summary>
/// Gets or sets the metadata fetchers.
/// </summary>
- public IReadOnlyList<LibraryOptionInfoDto> MetadataFetchers { get; set; } = Array.Empty<LibraryOptionInfoDto>();
+ public IReadOnlyList<LibraryOptionInfoDto> MetadataFetchers { get; set; } = [];
/// <summary>
/// Gets or sets the image fetchers.
/// </summary>
- public IReadOnlyList<LibraryOptionInfoDto> ImageFetchers { get; set; } = Array.Empty<LibraryOptionInfoDto>();
+ public IReadOnlyList<LibraryOptionInfoDto> ImageFetchers { get; set; } = [];
+
+ /// <summary>
+ /// Gets or sets the similar item providers.
+ /// </summary>
+ public IReadOnlyList<LibraryOptionInfoDto> SimilarItemProviders { get; set; } = [];
/// <summary>
/// Gets or sets the supported image types.
/// </summary>
- public IReadOnlyList<ImageType> SupportedImageTypes { get; set; } = Array.Empty<ImageType>();
+ public IReadOnlyList<ImageType> SupportedImageTypes { get; set; } = [];
/// <summary>
/// Gets or sets the default image options.
/// </summary>
- public IReadOnlyList<ImageOption> DefaultImageOptions { get; set; } = Array.Empty<ImageOption>();
+ public IReadOnlyList<ImageOption> DefaultImageOptions { get; set; } = [];
}
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
index 0abe981af8..1c1e014c8c 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
@@ -952,6 +952,17 @@ public sealed partial class BaseItemRepository
}
}
+ if (filter.HasAnyProviderIds is not null && filter.HasAnyProviderIds.Count > 0)
+ {
+ var includeAny = filter.HasAnyProviderIds
+ .SelectMany(kvp => kvp.Value.Select(v => $"{kvp.Key}:{v}"))
+ .ToArray();
+ if (includeAny.Length > 0)
+ {
+ baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.Any(f => includeAny.Contains(f)));
+ }
+ }
+
if (filter.HasImdbId.HasValue)
{
baseQuery = filter.HasImdbId.Value
diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
index fa82ea8663..8ae578b228 100644
--- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
+++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
@@ -351,6 +351,8 @@ namespace MediaBrowser.Controller.Entities
public Dictionary<string, string>? HasAnyProviderId { get; set; }
+ public Dictionary<string, string[]>? HasAnyProviderIds { get; set; }
+
public Guid[] AlbumArtistIds { get; set; }
public Guid[] BoxSetLibraryFolders { get; set; }
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;
+
+/// <summary>
+/// Provides similar items from the local library for a specific item type.
+/// Returns fully resolved BaseItems directly - no additional resolution needed.
+/// </summary>
+/// <typeparam name="TItemType">The type of item this provider handles.</typeparam>
+public interface ILocalSimilarItemsProvider<TItemType> : ISimilarItemsProvider
+ where TItemType : BaseItem
+{
+ /// <summary>
+ /// Gets similar items from the local library.
+ /// </summary>
+ /// <param name="item">The source item to find similar items for.</param>
+ /// <param name="query">The query options (user, limit, exclusions, etc.).</param>
+ /// <param name="cancellationToken">Cancellation token.</param>
+ /// <returns>The list of similar items from the library.</returns>
+ Task<IReadOnlyList<BaseItem>> 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;
+
+/// <summary>
+/// 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.
+/// </summary>
+/// <typeparam name="TItemType">The type of item this provider handles.</typeparam>
+public interface IRemoteSimilarItemsProvider<TItemType> : ISimilarItemsProvider
+ where TItemType : BaseItem
+{
+ /// <summary>
+ /// Gets similar item references from an external source as an async stream.
+ /// </summary>
+ /// <param name="item">The source item to find similar items for.</param>
+ /// <param name="query">The query options (user, limit, exclusions).</param>
+ /// <param name="cancellationToken">Cancellation token.</param>
+ /// <returns>An async enumerable of similar item references.</returns>
+ IAsyncEnumerable<SimilarItemReference> 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;
+
+/// <summary>
+/// Interface for managing similar items providers and operations.
+/// </summary>
+public interface ISimilarItemsManager
+{
+ /// <summary>
+ /// Registers similar items providers discovered through dependency injection.
+ /// </summary>
+ /// <param name="providers">The similar items providers to register.</param>
+ void AddParts(IEnumerable<ISimilarItemsProvider> providers);
+
+ /// <summary>
+ /// Gets the similar items providers for a specific item type.
+ /// </summary>
+ /// <typeparam name="T">The item type.</typeparam>
+ /// <returns>The list of similar items providers for that type.</returns>
+ IReadOnlyList<ISimilarItemsProvider> GetSimilarItemsProviders<T>()
+ where T : BaseItem;
+
+ /// <summary>
+ /// Gets similar items for the specified item.
+ /// </summary>
+ /// <param name="item">The source item to find similar items for.</param>
+ /// <param name="excludeArtistIds">Artist IDs to exclude from results.</param>
+ /// <param name="user">The user context.</param>
+ /// <param name="dtoOptions">The DTO options.</param>
+ /// <param name="limit">Maximum number of results.</param>
+ /// <param name="libraryOptions">The library options for provider configuration.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>The list of similar items.</returns>
+ Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(
+ BaseItem item,
+ IReadOnlyList<Guid> 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;
+
+/// <summary>
+/// Base marker interface for similar items providers.
+/// </summary>
+public interface ISimilarItemsProvider
+{
+ /// <summary>
+ /// Gets the name of the provider.
+ /// </summary>
+ string Name { get; }
+
+ /// <summary>
+ /// Gets the type of the provider.
+ /// </summary>
+ MetadataPluginType Type { get; }
+
+ /// <summary>
+ /// Gets the cache duration for results from this provider.
+ /// If null, results will not be cached.
+ /// </summary>
+ 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;
+
+/// <summary>
+/// A reference to a similar item by provider ID with a similarity score.
+/// </summary>
+public class SimilarItemReference
+{
+ /// <summary>
+ /// Gets or sets the provider name (e.g., "Tmdb", "MusicBrainzArtist").
+ /// </summary>
+ public required string ProviderName { get; set; }
+
+ /// <summary>
+ /// Gets or sets the provider ID value.
+ /// </summary>
+ public required string ProviderId { get; set; }
+
+ /// <summary>
+ /// Gets or sets the similarity score (0.0 to 1.0).
+ /// </summary>
+ 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;
+
+/// <summary>
+/// Query options for similar items requests.
+/// </summary>
+public class SimilarItemsQuery
+{
+ /// <summary>
+ /// Gets or sets the user context.
+ /// </summary>
+ public User? User { get; set; }
+
+ /// <summary>
+ /// Gets or sets the maximum number of results.
+ /// </summary>
+ public int? Limit { get; set; }
+
+ /// <summary>
+ /// Gets or sets the DTO options.
+ /// </summary>
+ public DtoOptions? DtoOptions { get; set; }
+
+ /// <summary>
+ /// Gets or sets the item IDs to exclude from results.
+ /// </summary>
+ public IReadOnlyList<Guid> ExcludeItemIds { get; set; } = [];
+
+ /// <summary>
+ /// Gets or sets the artist IDs to exclude from results.
+ /// </summary>
+ public IReadOnlyList<Guid> ExcludeArtistIds { get; set; } = [];
+}
diff --git a/MediaBrowser.Controller/Providers/IProviderManager.cs b/MediaBrowser.Controller/Providers/IProviderManager.cs
index 0d3a334dfb..c87f09a117 100644
--- a/MediaBrowser.Controller/Providers/IProviderManager.cs
+++ b/MediaBrowser.Controller/Providers/IProviderManager.cs
@@ -144,6 +144,17 @@ namespace MediaBrowser.Controller.Providers
where T : BaseItem;
/// <summary>
+ /// Gets the metadata providers for the provided item.
+ /// </summary>
+ /// <param name="item">The item.</param>
+ /// <param name="libraryOptions">The library options.</param>
+ /// <param name="includeDisabled">Whether to include disabled providers.</param>
+ /// <typeparam name="T">The type of metadata provider.</typeparam>
+ /// <returns>The metadata providers.</returns>
+ IEnumerable<IMetadataProvider<T>> GetMetadataProviders<T>(BaseItem item, LibraryOptions libraryOptions, bool includeDisabled)
+ where T : BaseItem;
+
+ /// <summary>
/// Gets the metadata savers for the provided item.
/// </summary>
/// <param name="item">The item.</param>
diff --git a/MediaBrowser.Model/Configuration/MetadataPluginType.cs b/MediaBrowser.Model/Configuration/MetadataPluginType.cs
index 670d6e3837..476060ceef 100644
--- a/MediaBrowser.Model/Configuration/MetadataPluginType.cs
+++ b/MediaBrowser.Model/Configuration/MetadataPluginType.cs
@@ -15,6 +15,8 @@ namespace MediaBrowser.Model.Configuration
MetadataSaver,
SubtitleFetcher,
LyricFetcher,
- MediaSegmentProvider
+ MediaSegmentProvider,
+ LocalSimilarityProvider,
+ SimilarityProvider
}
}
diff --git a/MediaBrowser.Model/Configuration/TypeOptions.cs b/MediaBrowser.Model/Configuration/TypeOptions.cs
index d0179e5aab..3aa85034e5 100644
--- a/MediaBrowser.Model/Configuration/TypeOptions.cs
+++ b/MediaBrowser.Model/Configuration/TypeOptions.cs
@@ -304,11 +304,13 @@ namespace MediaBrowser.Model.Configuration
public TypeOptions()
{
- MetadataFetchers = Array.Empty<string>();
- MetadataFetcherOrder = Array.Empty<string>();
- ImageFetchers = Array.Empty<string>();
- ImageFetcherOrder = Array.Empty<string>();
- ImageOptions = Array.Empty<ImageOption>();
+ MetadataFetchers = [];
+ MetadataFetcherOrder = [];
+ ImageFetchers = [];
+ ImageFetcherOrder = [];
+ ImageOptions = [];
+ SimilarItemProviders = [];
+ SimilarItemProviderOrder = [];
}
public string Type { get; set; }
@@ -323,6 +325,10 @@ namespace MediaBrowser.Model.Configuration
public ImageOption[] ImageOptions { get; set; }
+ public string[] SimilarItemProviders { get; set; }
+
+ public string[] SimilarItemProviderOrder { get; set; }
+
public ImageOption GetImageOptions(ImageType type)
{
foreach (var i in ImageOptions)
diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs
index d57e85c62f..7e1722e088 100644
--- a/MediaBrowser.Providers/Manager/ProviderManager.cs
+++ b/MediaBrowser.Providers/Manager/ProviderManager.cs
@@ -1,17 +1,20 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
+using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Mime;
+using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using AsyncKeyedLock;
using Jellyfin.Data.Enums;
using Jellyfin.Data.Events;
using Jellyfin.Extensions;
+using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using MediaBrowser.Controller.BaseItemManager;
@@ -64,6 +67,7 @@ namespace MediaBrowser.Providers.Manager
private readonly PriorityQueue<(Guid ItemId, MetadataRefreshOptions RefreshOptions), RefreshPriority> _refreshQueue = new();
private readonly IMemoryCache _memoryCache;
private readonly IMediaSegmentManager _mediaSegmentManager;
+ private readonly ISimilarItemsManager _similarItemsManager;
private readonly AsyncKeyedLocker<string> _imageSaveLock = new(o =>
{
o.PoolSize = 20;
@@ -101,6 +105,7 @@ namespace MediaBrowser.Providers.Manager
/// <param name="lyricManager">The lyric manager.</param>
/// <param name="memoryCache">The memory cache.</param>
/// <param name="mediaSegmentManager">The media segment manager.</param>
+ /// <param name="similarItemsManager">The similar items manager.</param>
public ProviderManager(
IHttpClientFactory httpClientFactory,
ISubtitleManager subtitleManager,
@@ -113,7 +118,8 @@ namespace MediaBrowser.Providers.Manager
IBaseItemManager baseItemManager,
ILyricManager lyricManager,
IMemoryCache memoryCache,
- IMediaSegmentManager mediaSegmentManager)
+ IMediaSegmentManager mediaSegmentManager,
+ ISimilarItemsManager similarItemsManager)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
@@ -127,6 +133,7 @@ namespace MediaBrowser.Providers.Manager
_lyricManager = lyricManager;
_memoryCache = memoryCache;
_mediaSegmentManager = mediaSegmentManager;
+ _similarItemsManager = similarItemsManager;
CollectionFolder.LibraryOptionsUpdated += OnLibraryOptionsUpdated;
}
@@ -687,6 +694,14 @@ namespace MediaBrowser.Providers.Manager
Type = MetadataPluginType.MediaSegmentProvider
}));
+ // Similar items providers
+ var similarItemsProviders = _similarItemsManager.GetSimilarItemsProviders<T>();
+ pluginList.AddRange(similarItemsProviders.Select(i => new MetadataPlugin
+ {
+ Name = i.Name,
+ Type = i.Type
+ }));
+
summary.Plugins = pluginList.ToArray();
var supportedImageTypes = imageProviders.OfType<IRemoteImageProvider>()
diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj
index ed0c63b97f..1022dc190e 100644
--- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj
+++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj
@@ -52,6 +52,8 @@
<EmbeddedResource Include="Plugins\AudioDb\Configuration\config.html" />
<None Remove="Plugins\Omdb\Configuration\config.html" />
<EmbeddedResource Include="Plugins\Omdb\Configuration\config.html" />
+ <None Remove="Plugins\ListenBrainz\Configuration\config.html" />
+ <EmbeddedResource Include="Plugins\ListenBrainz\Configuration\config.html" />
<None Remove="Plugins\MusicBrainz\Configuration\config.html" />
<EmbeddedResource Include="Plugins\MusicBrainz\Configuration\config.html" />
<None Remove="Plugins\StudioImages\Configuration\config.html" />
diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/Api/ListenBrainzLabsClient.cs b/MediaBrowser.Providers/Plugins/ListenBrainz/Api/ListenBrainzLabsClient.cs
new file mode 100644
index 0000000000..e57aa3ed1d
--- /dev/null
+++ b/MediaBrowser.Providers/Plugins/ListenBrainz/Api/ListenBrainzLabsClient.cs
@@ -0,0 +1,104 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Http;
+using System.Net.Http.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Providers.Plugins.ListenBrainz.Api.Models;
+using MediaBrowser.Providers.Plugins.ListenBrainz.Configuration;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Providers.Plugins.ListenBrainz.Api;
+
+/// <summary>
+/// Client for the ListenBrainz Labs API.
+/// </summary>
+public class ListenBrainzLabsClient
+{
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly ILogger<ListenBrainzLabsClient> _logger;
+ private readonly Lock _rateLimitLock = new();
+
+ private DateTime _lastRequestTime = DateTime.MinValue;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ListenBrainzLabsClient"/> class.
+ /// </summary>
+ /// <param name="httpClientFactory">The HTTP client factory.</param>
+ /// <param name="logger">The logger.</param>
+ public ListenBrainzLabsClient(
+ IHttpClientFactory httpClientFactory,
+ ILogger<ListenBrainzLabsClient> logger)
+ {
+ _httpClientFactory = httpClientFactory;
+ _logger = logger;
+ }
+
+ /// <summary>
+ /// Gets similar artists for the given MusicBrainz artist ID.
+ /// </summary>
+ /// <param name="artistMbid">The MusicBrainz artist ID.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>A list of similar artist MusicBrainz IDs ordered by similarity score.</returns>
+ public async Task<IReadOnlyList<Guid>> GetSimilarArtistsAsync(
+ Guid artistMbid,
+ CancellationToken cancellationToken)
+ {
+ var config = ListenBrainzPlugin.Instance?.Configuration;
+ var baseUrl = config?.LabsServer ?? PluginConfiguration.DefaultLabsServer;
+ var algorithm = config?.AlgorithmString ?? new PluginConfiguration().AlgorithmString;
+ var rateLimit = config?.RateLimit ?? PluginConfiguration.DefaultRateLimit;
+
+ // Enforce rate limit
+ EnforceRateLimit(rateLimit);
+
+ var url = $"{baseUrl}/similar-artists/json?artist_mbids={artistMbid}&algorithm={algorithm}";
+
+ _logger.LogDebug("Fetching similar artists from ListenBrainz Labs: {Url}", url);
+
+ try
+ {
+ var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
+ var response = await httpClient.GetFromJsonAsync<List<SimilarArtistData>>(url, cancellationToken).ConfigureAwait(false);
+
+ if (response is null || response.Count == 0)
+ {
+ _logger.LogDebug("No similar artists found for {ArtistMbid}", artistMbid);
+ return [];
+ }
+
+ var similarMbids = response
+ .Where(a => !a.ArtistMbid.Equals(artistMbid)) // Exclude the source artist
+ .OrderByDescending(a => a.Score)
+ .Select(a => a.ArtistMbid)
+ .ToList();
+
+ _logger.LogDebug("Found {Count} similar artists for {ArtistMbid}", similarMbids.Count, artistMbid);
+
+ return similarMbids;
+ }
+ catch (HttpRequestException ex)
+ {
+ _logger.LogWarning(ex, "Failed to fetch similar artists from ListenBrainz Labs for {ArtistMbid}", artistMbid);
+ return [];
+ }
+ }
+
+ private void EnforceRateLimit(double rateLimitSeconds)
+ {
+ lock (_rateLimitLock)
+ {
+ var timeSinceLastRequest = DateTime.UtcNow - _lastRequestTime;
+ var requiredDelay = TimeSpan.FromSeconds(rateLimitSeconds) - timeSinceLastRequest;
+
+ if (requiredDelay > TimeSpan.Zero)
+ {
+ Thread.Sleep(requiredDelay);
+ }
+
+ _lastRequestTime = DateTime.UtcNow;
+ }
+ }
+}
diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/Api/Models/SimilarArtistData.cs b/MediaBrowser.Providers/Plugins/ListenBrainz/Api/Models/SimilarArtistData.cs
new file mode 100644
index 0000000000..237f33ee3a
--- /dev/null
+++ b/MediaBrowser.Providers/Plugins/ListenBrainz/Api/Models/SimilarArtistData.cs
@@ -0,0 +1,28 @@
+using System;
+using System.Text.Json.Serialization;
+
+namespace MediaBrowser.Providers.Plugins.ListenBrainz.Api.Models;
+
+/// <summary>
+/// A similar artist data entry from the ListenBrainz Labs API.
+/// </summary>
+public class SimilarArtistData
+{
+ /// <summary>
+ /// Gets or sets the MusicBrainz artist ID.
+ /// </summary>
+ [JsonPropertyName("artist_mbid")]
+ public Guid ArtistMbid { get; set; }
+
+ /// <summary>
+ /// Gets or sets the artist name.
+ /// </summary>
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ /// <summary>
+ /// Gets or sets the similarity score.
+ /// </summary>
+ [JsonPropertyName("score")]
+ public double Score { get; set; }
+}
diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/Api/Models/SimilarArtistsResponse.cs b/MediaBrowser.Providers/Plugins/ListenBrainz/Api/Models/SimilarArtistsResponse.cs
new file mode 100644
index 0000000000..12e8f25dcc
--- /dev/null
+++ b/MediaBrowser.Providers/Plugins/ListenBrainz/Api/Models/SimilarArtistsResponse.cs
@@ -0,0 +1,16 @@
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace MediaBrowser.Providers.Plugins.ListenBrainz.Api.Models;
+
+/// <summary>
+/// Response from ListenBrainz Labs similar-artists endpoint.
+/// </summary>
+public class SimilarArtistsResponse
+{
+ /// <summary>
+ /// Gets or sets the list of similar artists.
+ /// </summary>
+ [JsonPropertyName("data")]
+ public IReadOnlyList<SimilarArtistData>? Data { get; set; }
+}
diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/PluginConfiguration.cs
new file mode 100644
index 0000000000..c80d0f7218
--- /dev/null
+++ b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/PluginConfiguration.cs
@@ -0,0 +1,60 @@
+using MediaBrowser.Model.Plugins;
+
+namespace MediaBrowser.Providers.Plugins.ListenBrainz.Configuration;
+
+/// <summary>
+/// ListenBrainz plugin configuration.
+/// </summary>
+public class PluginConfiguration : BasePluginConfiguration
+{
+ /// <summary>
+ /// The default Labs API server URL.
+ /// </summary>
+ public const string DefaultLabsServer = "https://labs.api.listenbrainz.org";
+
+ /// <summary>
+ /// The default rate limit in seconds.
+ /// </summary>
+ public const double DefaultRateLimit = 1.0;
+
+ private string _labsServer = DefaultLabsServer;
+ private double _rateLimit = DefaultRateLimit;
+
+ /// <summary>
+ /// Gets or sets the Labs API server URL.
+ /// </summary>
+ public string LabsServer
+ {
+ get => _labsServer;
+ set => _labsServer = string.IsNullOrWhiteSpace(value) ? DefaultLabsServer : value.TrimEnd('/');
+ }
+
+ /// <summary>
+ /// Gets or sets the similarity algorithm.
+ /// </summary>
+ public SimilarityAlgorithm Algorithm { get; set; } = SimilarityAlgorithm.SessionBased1825Days;
+
+ /// <summary>
+ /// Gets or sets the rate limit in seconds.
+ /// </summary>
+ public double RateLimit
+ {
+ get => _rateLimit;
+ set
+ {
+ if (value < DefaultRateLimit && _labsServer == DefaultLabsServer)
+ {
+ _rateLimit = DefaultRateLimit;
+ }
+ else
+ {
+ _rateLimit = value;
+ }
+ }
+ }
+
+ /// <summary>
+ /// Gets the algorithm string for the API call.
+ /// </summary>
+ public string AlgorithmString => Algorithm.ToApiString();
+}
diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/SimilarityAlgorithm.cs b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/SimilarityAlgorithm.cs
new file mode 100644
index 0000000000..f297d99f6d
--- /dev/null
+++ b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/SimilarityAlgorithm.cs
@@ -0,0 +1,37 @@
+namespace MediaBrowser.Providers.Plugins.ListenBrainz.Configuration;
+
+/// <summary>
+/// Available similarity algorithms for ListenBrainz Labs API.
+/// </summary>
+public enum SimilarityAlgorithm
+{
+ /// <summary>
+ /// Session-based algorithm analyzing ~5 years of listening data.
+ /// </summary>
+ SessionBased1825Days = 0,
+
+ /// <summary>
+ /// Session-based algorithm analyzing ~5 years of listening data (alternate).
+ /// </summary>
+ SessionBased1800Days = 1,
+
+ /// <summary>
+ /// Session-based algorithm analyzing ~20 years of listening data.
+ /// </summary>
+ SessionBased7500Days = 2,
+
+ /// <summary>
+ /// Session-based algorithm analyzing ~20 years with higher contribution threshold.
+ /// </summary>
+ SessionBased7500DaysHighContribution = 3,
+
+ /// <summary>
+ /// Session-based algorithm analyzing ~25 years of listening data.
+ /// </summary>
+ SessionBased9000Days = 4,
+
+ /// <summary>
+ /// Session-based algorithm analyzing ~75 days of recent listening data.
+ /// </summary>
+ SessionBased75Days = 5
+}
diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/SimilarityAlgorithmExtensions.cs b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/SimilarityAlgorithmExtensions.cs
new file mode 100644
index 0000000000..f7874dbae8
--- /dev/null
+++ b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/SimilarityAlgorithmExtensions.cs
@@ -0,0 +1,23 @@
+namespace MediaBrowser.Providers.Plugins.ListenBrainz.Configuration;
+
+/// <summary>
+/// Extension methods for <see cref="SimilarityAlgorithm"/>.
+/// </summary>
+public static class SimilarityAlgorithmExtensions
+{
+ /// <summary>
+ /// Gets the API string value for the algorithm.
+ /// </summary>
+ /// <param name="algorithm">The algorithm.</param>
+ /// <returns>The API string value.</returns>
+ public static string ToApiString(this SimilarityAlgorithm algorithm) => algorithm switch
+ {
+ SimilarityAlgorithm.SessionBased1825Days => "session_based_days_1825_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30",
+ SimilarityAlgorithm.SessionBased1800Days => "session_based_days_1800_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30",
+ SimilarityAlgorithm.SessionBased7500Days => "session_based_days_7500_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30",
+ SimilarityAlgorithm.SessionBased7500DaysHighContribution => "session_based_days_7500_session_300_contribution_5_threshold_10_limit_100_filter_True_skip_30",
+ SimilarityAlgorithm.SessionBased9000Days => "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30",
+ SimilarityAlgorithm.SessionBased75Days => "session_based_days_75_session_300_contribution_5_threshold_10_limit_100_filter_True_skip_30",
+ _ => "session_based_days_1825_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30"
+ };
+}
diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html
new file mode 100644
index 0000000000..3dd1033fdf
--- /dev/null
+++ b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html
@@ -0,0 +1,87 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>ListenBrainz</title>
+</head>
+<body>
+ <div id="configPage" data-role="page" class="page type-interior pluginConfigurationPage configPage" data-require="emby-input,emby-button,emby-select">
+ <div data-role="content">
+ <div class="content-primary">
+ <h1>ListenBrainz</h1>
+ <p>Get similar artist recommendations from ListenBrainz Labs.</p>
+ <form class="configForm">
+ <div class="inputContainer">
+ <input is="emby-input" type="text" id="labsServer" required label="Labs API Server" />
+ <div class="fieldDescription">The ListenBrainz Labs API server URL. Default: https://labs.api.listenbrainz.org</div>
+ </div>
+ <div class="selectContainer">
+ <label class="selectLabel" for="algorithm">Similarity Algorithm</label>
+ <select is="emby-select" id="algorithm" class="emby-select-withcolor">
+ <option value="0" selected>~5 years / 1825 days (Recommended)</option>
+ <option value="1">~5 years / 1800 days</option>
+ <option value="2">~20 years / 7500 days</option>
+ <option value="3">~20 years / 7500 days (high contribution)</option>
+ <option value="4">~25 years / 9000 days</option>
+ <option value="5">~75 days (recent)</option>
+ </select>
+ <div class="fieldDescription">The algorithm used for artist similarity calculation.</div>
+ </div>
+ <div class="inputContainer">
+ <input is="emby-input" type="number" id="rateLimit" required pattern="[0-9]*" min="0" max="10" step=".01" label="Rate Limit (seconds)" />
+ <div class="fieldDescription">Span of time between requests in seconds. The official server is rate limited to one request per second.</div>
+ </div>
+ <br />
+ <div>
+ <button is="emby-button" type="submit" class="raised button-submit block"><span>Save</span></button>
+ </div>
+ </form>
+ </div>
+ </div>
+ <script type="text/javascript">
+ var ListenBrainzPluginConfig = {
+ uniquePluginId: "a5b2e8c1-9d4f-4a3b-8c7e-6f1a2b3c4d5e"
+ };
+
+ document.querySelector('.configPage')
+ .addEventListener('pageshow', function () {
+ Dashboard.showLoadingMsg();
+ ApiClient.getPluginConfiguration(ListenBrainzPluginConfig.uniquePluginId).then(function (config) {
+ var labsServer = document.querySelector('#labsServer');
+ labsServer.value = config.LabsServer;
+ labsServer.dispatchEvent(new Event('change', {
+ bubbles: true,
+ cancelable: false
+ }));
+
+ document.querySelector('#algorithm').value = config.Algorithm;
+
+ var rateLimit = document.querySelector('#rateLimit');
+ rateLimit.value = config.RateLimit;
+ rateLimit.dispatchEvent(new Event('change', {
+ bubbles: true,
+ cancelable: false
+ }));
+
+ Dashboard.hideLoadingMsg();
+ });
+ });
+
+ document.querySelector('.configForm')
+ .addEventListener('submit', function (e) {
+ Dashboard.showLoadingMsg();
+
+ ApiClient.getPluginConfiguration(ListenBrainzPluginConfig.uniquePluginId).then(function (config) {
+ config.LabsServer = document.querySelector('#labsServer').value;
+ config.Algorithm = parseInt(document.querySelector('#algorithm').value, 10);
+ config.RateLimit = document.querySelector('#rateLimit').value;
+
+ ApiClient.updatePluginConfiguration(ListenBrainzPluginConfig.uniquePluginId, config).then(Dashboard.processPluginConfigurationUpdateResult);
+ });
+
+ e.preventDefault();
+ return false;
+ });
+ </script>
+ </div>
+</body>
+</html>
diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/ListenBrainzPlugin.cs b/MediaBrowser.Providers/Plugins/ListenBrainz/ListenBrainzPlugin.cs
new file mode 100644
index 0000000000..3e5ea42f44
--- /dev/null
+++ b/MediaBrowser.Providers/Plugins/ListenBrainz/ListenBrainzPlugin.cs
@@ -0,0 +1,53 @@
+using System;
+using System.Collections.Generic;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Plugins;
+using MediaBrowser.Model.Plugins;
+using MediaBrowser.Model.Serialization;
+using MediaBrowser.Providers.Plugins.ListenBrainz.Configuration;
+
+namespace MediaBrowser.Providers.Plugins.ListenBrainz;
+
+/// <summary>
+/// ListenBrainz plugin instance.
+/// </summary>
+public class ListenBrainzPlugin : BasePlugin<PluginConfiguration>, IHasWebPages
+{
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ListenBrainzPlugin"/> class.
+ /// </summary>
+ /// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
+ /// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
+ public ListenBrainzPlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
+ : base(applicationPaths, xmlSerializer)
+ {
+ Instance = this;
+ }
+
+ /// <summary>
+ /// Gets the current plugin instance.
+ /// </summary>
+ public static ListenBrainzPlugin? Instance { get; private set; }
+
+ /// <inheritdoc />
+ public override Guid Id => new("a5b2e8c1-9d4f-4a3b-8c7e-6f1a2b3c4d5e");
+
+ /// <inheritdoc />
+ public override string Name => "ListenBrainz";
+
+ /// <inheritdoc />
+ public override string Description => "Get similar artist recommendations from ListenBrainz Labs.";
+
+ /// <inheritdoc />
+ public override string ConfigurationFileName => "Jellyfin.Plugin.ListenBrainz.xml";
+
+ /// <inheritdoc />
+ public IEnumerable<PluginPageInfo> GetPages()
+ {
+ yield return new PluginPageInfo
+ {
+ Name = Name,
+ EmbeddedResourcePath = GetType().Namespace + ".Configuration.config.html"
+ };
+ }
+}
diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/ListenBrainzSimilarArtistProvider.cs b/MediaBrowser.Providers/Plugins/ListenBrainz/ListenBrainzSimilarArtistProvider.cs
new file mode 100644
index 0000000000..3f03a724c5
--- /dev/null
+++ b/MediaBrowser.Providers/Plugins/ListenBrainz/ListenBrainzSimilarArtistProvider.cs
@@ -0,0 +1,82 @@
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Providers.Plugins.ListenBrainz.Api;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Providers.Plugins.ListenBrainz;
+
+/// <summary>
+/// ListenBrainz-based similar items provider for music artists.
+/// </summary>
+public class ListenBrainzSimilarArtistProvider : IRemoteSimilarItemsProvider<MusicArtist>
+{
+ private readonly ListenBrainzLabsClient _labsClient;
+ private readonly ILogger<ListenBrainzSimilarArtistProvider> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ListenBrainzSimilarArtistProvider"/> class.
+ /// </summary>
+ /// <param name="labsClient">The ListenBrainz Labs API client.</param>
+ /// <param name="logger">The logger.</param>
+ public ListenBrainzSimilarArtistProvider(
+ ListenBrainzLabsClient labsClient,
+ ILogger<ListenBrainzSimilarArtistProvider> logger)
+ {
+ _labsClient = labsClient;
+ _logger = logger;
+ }
+
+ /// <inheritdoc/>
+ public string Name => "ListenBrainz";
+
+ /// <inheritdoc/>
+ public MetadataPluginType Type => MetadataPluginType.SimilarityProvider;
+
+ /// <inheritdoc/>
+ public TimeSpan? CacheDuration => TimeSpan.FromDays(14);
+
+ /// <inheritdoc/>
+ public async IAsyncEnumerable<SimilarItemReference> GetSimilarItemsAsync(
+ MusicArtist item,
+ SimilarItemsQuery query,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(item);
+ ArgumentNullException.ThrowIfNull(query);
+
+ if (!item.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out var mbidStr) || !Guid.TryParse(mbidStr, out var mbid))
+ {
+ _logger.LogDebug("No MusicBrainz Artist ID found for {ArtistName}", item.Name);
+ yield break;
+ }
+
+ IReadOnlyList<Guid> similarMbids;
+ try
+ {
+ similarMbids = await _labsClient.GetSimilarArtistsAsync(mbid, cancellationToken).ConfigureAwait(false);
+ }
+ catch (HttpRequestException ex)
+ {
+ _logger.LogWarning(ex, "Failed to fetch similar artists from ListenBrainz for {ArtistMbid}", mbid);
+ yield break;
+ }
+
+ var providerName = MetadataProvider.MusicBrainzArtist.ToString();
+
+ foreach (var similarMbid in similarMbids)
+ {
+ yield return new SimilarItemReference
+ {
+ ProviderName = providerName,
+ ProviderId = similarMbid.ToString()
+ };
+ }
+ }
+}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs
new file mode 100644
index 0000000000..8cf4e3b6f5
--- /dev/null
+++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs
@@ -0,0 +1,89 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Entities;
+using Microsoft.Extensions.Logging;
+using Movie = MediaBrowser.Controller.Entities.Movies.Movie;
+
+namespace MediaBrowser.Providers.Plugins.Tmdb.Movies;
+
+/// <summary>
+/// TMDb-based similar items provider for movies.
+/// </summary>
+public class TmdbMovieSimilarProvider : IRemoteSimilarItemsProvider<Movie>
+{
+ private readonly TmdbClientManager _tmdbClientManager;
+ private readonly ILogger<TmdbMovieSimilarProvider> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="TmdbMovieSimilarProvider"/> class.
+ /// </summary>
+ /// <param name="tmdbClientManager">The TMDb client manager.</param>
+ /// <param name="logger">The logger.</param>
+ public TmdbMovieSimilarProvider(TmdbClientManager tmdbClientManager, ILogger<TmdbMovieSimilarProvider> logger)
+ {
+ _tmdbClientManager = tmdbClientManager;
+ _logger = logger;
+ }
+
+ /// <inheritdoc/>
+ public string Name => TmdbUtils.ProviderName;
+
+ /// <inheritdoc/>
+ public MetadataPluginType Type => MetadataPluginType.SimilarityProvider;
+
+ /// <inheritdoc/>
+ public TimeSpan? CacheDuration => TimeSpan.FromDays(7);
+
+ /// <inheritdoc/>
+ public async IAsyncEnumerable<SimilarItemReference> GetSimilarItemsAsync(
+ Movie item,
+ SimilarItemsQuery query,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ if (!item.TryGetProviderId(MetadataProvider.Tmdb, out var tmdbIdStr) || !int.TryParse(tmdbIdStr, CultureInfo.InvariantCulture, out var tmdbId))
+ {
+ yield break;
+ }
+
+ var providerName = MetadataProvider.Tmdb.ToString();
+ var page = 0;
+ var totalPages = 1;
+
+ while (page <= totalPages && !cancellationToken.IsCancellationRequested)
+ {
+ IReadOnlyList<TMDbLib.Objects.Search.SearchMovie> pageResults;
+ try
+ {
+ (pageResults, totalPages) = await _tmdbClientManager
+ .GetMovieSimilarPageAsync(tmdbId, page, TmdbUtils.GetImageLanguagesParam(string.Empty), cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to get similar movies from TMDb for {TmdbId} page {Page}", tmdbId, page);
+ yield break;
+ }
+
+ if (pageResults.Count == 0)
+ {
+ yield break;
+ }
+
+ foreach (var similar in pageResults)
+ {
+ yield return new SimilarItemReference
+ {
+ ProviderName = providerName,
+ ProviderId = similar.Id.ToString(CultureInfo.InvariantCulture)
+ };
+ }
+
+ page++;
+ }
+ }
+}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs
new file mode 100644
index 0000000000..e713c37be8
--- /dev/null
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs
@@ -0,0 +1,89 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Entities;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Providers.Plugins.Tmdb.TV;
+
+/// <summary>
+/// TMDb-based similar items provider for TV series.
+/// </summary>
+public class TmdbSeriesSimilarProvider : IRemoteSimilarItemsProvider<Series>
+{
+ private readonly TmdbClientManager _tmdbClientManager;
+ private readonly ILogger<TmdbSeriesSimilarProvider> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="TmdbSeriesSimilarProvider"/> class.
+ /// </summary>
+ /// <param name="tmdbClientManager">The TMDb client manager.</param>
+ /// <param name="logger">The logger.</param>
+ public TmdbSeriesSimilarProvider(TmdbClientManager tmdbClientManager, ILogger<TmdbSeriesSimilarProvider> logger)
+ {
+ _tmdbClientManager = tmdbClientManager;
+ _logger = logger;
+ }
+
+ /// <inheritdoc/>
+ public string Name => TmdbUtils.ProviderName;
+
+ /// <inheritdoc/>
+ public MetadataPluginType Type => MetadataPluginType.SimilarityProvider;
+
+ /// <inheritdoc/>
+ public TimeSpan? CacheDuration => TimeSpan.FromDays(7);
+
+ /// <inheritdoc/>
+ public async IAsyncEnumerable<SimilarItemReference> GetSimilarItemsAsync(
+ Series item,
+ SimilarItemsQuery query,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ if (!item.TryGetProviderId(MetadataProvider.Tmdb, out var tmdbIdStr) || !int.TryParse(tmdbIdStr, CultureInfo.InvariantCulture, out var tmdbId))
+ {
+ yield break;
+ }
+
+ var providerName = MetadataProvider.Tmdb.ToString();
+ var page = 1;
+ var totalPages = 1;
+
+ while (page <= totalPages && !cancellationToken.IsCancellationRequested)
+ {
+ IReadOnlyList<TMDbLib.Objects.Search.SearchTv> pageResults;
+ try
+ {
+ (pageResults, totalPages) = await _tmdbClientManager
+ .GetSeriesSimilarPageAsync(tmdbId, page, TmdbUtils.GetImageLanguagesParam(string.Empty), cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to get similar TV shows from TMDb for {TmdbId} page {Page}", tmdbId, page);
+ yield break;
+ }
+
+ if (pageResults.Count == 0)
+ {
+ yield break;
+ }
+
+ foreach (var similar in pageResults)
+ {
+ yield return new SimilarItemReference
+ {
+ ProviderName = providerName,
+ ProviderId = similar.Id.ToString(CultureInfo.InvariantCulture)
+ };
+ }
+
+ page++;
+ }
+ }
+}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs
index 274db347ba..174f1546a7 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs
@@ -505,6 +505,54 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
}
/// <summary>
+ /// Gets a single page of similar movies for a movie from the TMDb API.
+ /// </summary>
+ /// <param name="tmdbId">The TMDb id of the movie.</param>
+ /// <param name="page">The page number to fetch (1-based).</param>
+ /// <param name="language">The language for results.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>A tuple containing the list of similar movies and the total number of pages available.</returns>
+ public async Task<(IReadOnlyList<SearchMovie> Results, int TotalPages)> GetMovieSimilarPageAsync(int tmdbId, int page, string? language, CancellationToken cancellationToken)
+ {
+ await EnsureClientConfigAsync().ConfigureAwait(false);
+
+ var searchResults = await _tmDbClient
+ .GetMovieSimilarAsync(tmdbId, language, page, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (searchResults?.Results is null || searchResults.Results.Count == 0)
+ {
+ return ([], 0);
+ }
+
+ return (searchResults.Results, searchResults.TotalPages);
+ }
+
+ /// <summary>
+ /// Gets a single page of similar TV shows for a series from the TMDb API.
+ /// </summary>
+ /// <param name="tmdbId">The TMDb id of the TV show.</param>
+ /// <param name="page">The page number to fetch (1-based).</param>
+ /// <param name="language">The language for results.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>A tuple containing the list of similar TV shows and the total number of pages available.</returns>
+ public async Task<(IReadOnlyList<SearchTv> Results, int TotalPages)> GetSeriesSimilarPageAsync(int tmdbId, int page, string? language, CancellationToken cancellationToken)
+ {
+ await EnsureClientConfigAsync().ConfigureAwait(false);
+
+ var searchResults = await _tmDbClient
+ .GetTvShowSimilarAsync(tmdbId, language, page, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (searchResults?.Results is null || searchResults.Results.Count == 0)
+ {
+ return ([], 0);
+ }
+
+ return (searchResults.Results, searchResults.TotalPages);
+ }
+
+ /// <summary>
/// Handles bad path checking and builds the absolute url.
/// </summary>
/// <param name="size">The image size to fetch.</param>
diff --git a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
index 87e7a4b564..5749944fcd 100644
--- a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
+++ b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
@@ -576,7 +576,8 @@ namespace Jellyfin.Providers.Tests.Manager
baseItemManager!,
Mock.Of<ILyricManager>(),
Mock.Of<IMemoryCache>(),
- Mock.Of<IMediaSegmentManager>());
+ Mock.Of<IMediaSegmentManager>(),
+ Mock.Of<ISimilarItemsManager>());
return providerManager;
}