From 4ebce3907062ade1937440628eebd665440b338d Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 3 May 2026 23:43:01 +0200 Subject: Implement Similarity providers --- Emby.Server.Implementations/ApplicationHost.cs | 14 + .../SimilarItems/AudioSimilarItemsProvider.cs | 55 +++ .../LiveTvProgramSimilarItemsProvider.cs | 94 +++++ .../SimilarItems/MovieSimilarItemsProvider.cs | 79 ++++ .../SimilarItems/MusicAlbumSimilarItemsProvider.cs | 55 +++ .../MusicArtistSimilarItemsProvider.cs | 55 +++ .../SimilarItems/SeriesSimilarItemsProvider.cs | 54 +++ .../Library/SimilarItems/SimilarItemsManager.cs | 423 +++++++++++++++++++++ Jellyfin.Api/Controllers/LibraryController.cs | 78 ++-- .../Models/LibraryDtos/LibraryTypeOptionsDto.cs | 14 +- .../Item/BaseItemRepository.TranslateQuery.cs | 11 + .../Entities/InternalItemsQuery.cs | 2 + .../Library/ILocalSimilarItemsProvider.cs | 27 ++ .../Library/IRemoteSimilarItemsProvider.cs | 26 ++ .../Library/ISimilarItemsManager.cs | 50 +++ .../Library/ISimilarItemsProvider.cs | 26 ++ .../Library/SimilarItemReference.cs | 22 ++ .../Library/SimilarItemsQuery.cs | 37 ++ .../Providers/IProviderManager.cs | 11 + .../Configuration/MetadataPluginType.cs | 4 +- MediaBrowser.Model/Configuration/TypeOptions.cs | 16 +- MediaBrowser.Providers/Manager/ProviderManager.cs | 17 +- .../MediaBrowser.Providers.csproj | 2 + .../ListenBrainz/Api/ListenBrainzLabsClient.cs | 104 +++++ .../ListenBrainz/Api/Models/SimilarArtistData.cs | 28 ++ .../Api/Models/SimilarArtistsResponse.cs | 16 + .../Configuration/PluginConfiguration.cs | 60 +++ .../Configuration/SimilarityAlgorithm.cs | 37 ++ .../Configuration/SimilarityAlgorithmExtensions.cs | 23 ++ .../Plugins/ListenBrainz/Configuration/config.html | 87 +++++ .../Plugins/ListenBrainz/ListenBrainzPlugin.cs | 53 +++ .../ListenBrainzSimilarArtistProvider.cs | 82 ++++ .../Tmdb/Movies/TmdbMovieSimilarProvider.cs | 89 +++++ .../Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs | 89 +++++ .../Plugins/Tmdb/TmdbClientManager.cs | 48 +++ .../Manager/ProviderManagerTests.cs | 3 +- 36 files changed, 1830 insertions(+), 61 deletions(-) create mode 100644 Emby.Server.Implementations/Library/SimilarItems/AudioSimilarItemsProvider.cs create mode 100644 Emby.Server.Implementations/Library/SimilarItems/LiveTvProgramSimilarItemsProvider.cs create mode 100644 Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs create mode 100644 Emby.Server.Implementations/Library/SimilarItems/MusicAlbumSimilarItemsProvider.cs create mode 100644 Emby.Server.Implementations/Library/SimilarItems/MusicArtistSimilarItemsProvider.cs create mode 100644 Emby.Server.Implementations/Library/SimilarItems/SeriesSimilarItemsProvider.cs create mode 100644 Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs create mode 100644 MediaBrowser.Controller/Library/ILocalSimilarItemsProvider.cs create mode 100644 MediaBrowser.Controller/Library/IRemoteSimilarItemsProvider.cs create mode 100644 MediaBrowser.Controller/Library/ISimilarItemsManager.cs create mode 100644 MediaBrowser.Controller/Library/ISimilarItemsProvider.cs create mode 100644 MediaBrowser.Controller/Library/SimilarItemReference.cs create mode 100644 MediaBrowser.Controller/Library/SimilarItemsQuery.cs create mode 100644 MediaBrowser.Providers/Plugins/ListenBrainz/Api/ListenBrainzLabsClient.cs create mode 100644 MediaBrowser.Providers/Plugins/ListenBrainz/Api/Models/SimilarArtistData.cs create mode 100644 MediaBrowser.Providers/Plugins/ListenBrainz/Api/Models/SimilarArtistsResponse.cs create mode 100644 MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/PluginConfiguration.cs create mode 100644 MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/SimilarityAlgorithm.cs create mode 100644 MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/SimilarityAlgorithmExtensions.cs create mode 100644 MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html create mode 100644 MediaBrowser.Providers/Plugins/ListenBrainz/ListenBrainzPlugin.cs create mode 100644 MediaBrowser.Providers/Plugins/ListenBrainz/ListenBrainzSimilarArtistProvider.cs create mode 100644 MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs create mode 100644 MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs 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(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddSingleton(NetManager); @@ -537,6 +547,8 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); @@ -694,6 +706,8 @@ namespace Emby.Server.Implementations GetExports()); Resolve().AddParts(GetExports()); + + Resolve().AddParts(GetExports()); } /// 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; + +/// +/// Provides similar items for audio tracks. +/// +public class AudioSimilarItemsProvider : ILocalSimilarItemsProvider /// Instance of the interface. + /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. @@ -70,6 +72,7 @@ public class LibraryController : BaseJellyfinApiController /// Instance of the interface. 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 /// Optional. Filter by user id, and attach user data. /// Optional. The maximum number of records to return. /// 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. + /// The cancellation token. /// Similar items returned. /// A containing the similar items. [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> GetSimilarItems( + public async Task>> 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(); - 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( - 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()) 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 /// /// Gets or sets the metadata fetchers. /// - public IReadOnlyList MetadataFetchers { get; set; } = Array.Empty(); + public IReadOnlyList MetadataFetchers { get; set; } = []; /// /// Gets or sets the image fetchers. /// - public IReadOnlyList ImageFetchers { get; set; } = Array.Empty(); + public IReadOnlyList ImageFetchers { get; set; } = []; + + /// + /// Gets or sets the similar item providers. + /// + public IReadOnlyList SimilarItemProviders { get; set; } = []; /// /// Gets or sets the supported image types. /// - public IReadOnlyList SupportedImageTypes { get; set; } = Array.Empty(); + public IReadOnlyList SupportedImageTypes { get; set; } = []; /// /// Gets or sets the default image options. /// - public IReadOnlyList DefaultImageOptions { get; set; } = Array.Empty(); + public IReadOnlyList 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? HasAnyProviderId { get; set; } + public Dictionary? 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; + +/// +/// Provides similar items from the local library for a specific item type. +/// Returns fully resolved BaseItems directly - no additional resolution needed. +/// +/// The type of item this provider handles. +public interface ILocalSimilarItemsProvider : ISimilarItemsProvider + where TItemType : BaseItem +{ + /// + /// Gets similar items from the local library. + /// + /// The source item to find similar items for. + /// The query options (user, limit, exclusions, etc.). + /// Cancellation token. + /// The list of similar items from the library. + Task> GetSimilarItemsAsync( + TItemType item, + SimilarItemsQuery query, + CancellationToken cancellationToken); +} diff --git a/MediaBrowser.Controller/Library/IRemoteSimilarItemsProvider.cs b/MediaBrowser.Controller/Library/IRemoteSimilarItemsProvider.cs new file mode 100644 index 0000000000..a77b6628d9 --- /dev/null +++ b/MediaBrowser.Controller/Library/IRemoteSimilarItemsProvider.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Threading; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Library; + +/// +/// Provides similar item references from remote/external sources for a specific item type. +/// Returns lightweight references with ProviderIds that the manager resolves to library items. +/// +/// The type of item this provider handles. +public interface IRemoteSimilarItemsProvider : ISimilarItemsProvider + where TItemType : BaseItem +{ + /// + /// Gets similar item references from an external source as an async stream. + /// + /// The source item to find similar items for. + /// The query options (user, limit, exclusions). + /// Cancellation token. + /// An async enumerable of similar item references. + IAsyncEnumerable GetSimilarItemsAsync( + TItemType item, + SimilarItemsQuery query, + CancellationToken cancellationToken); +} diff --git a/MediaBrowser.Controller/Library/ISimilarItemsManager.cs b/MediaBrowser.Controller/Library/ISimilarItemsManager.cs new file mode 100644 index 0000000000..0ced6f71ee --- /dev/null +++ b/MediaBrowser.Controller/Library/ISimilarItemsManager.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Configuration; + +namespace MediaBrowser.Controller.Library; + +/// +/// Interface for managing similar items providers and operations. +/// +public interface ISimilarItemsManager +{ + /// + /// Registers similar items providers discovered through dependency injection. + /// + /// The similar items providers to register. + void AddParts(IEnumerable providers); + + /// + /// Gets the similar items providers for a specific item type. + /// + /// The item type. + /// The list of similar items providers for that type. + IReadOnlyList GetSimilarItemsProviders() + where T : BaseItem; + + /// + /// Gets similar items for the specified item. + /// + /// The source item to find similar items for. + /// Artist IDs to exclude from results. + /// The user context. + /// The DTO options. + /// Maximum number of results. + /// The library options for provider configuration. + /// The cancellation token. + /// The list of similar items. + Task> GetSimilarItemsAsync( + BaseItem item, + IReadOnlyList excludeArtistIds, + User? user, + DtoOptions dtoOptions, + int? limit, + LibraryOptions? libraryOptions, + CancellationToken cancellationToken); +} diff --git a/MediaBrowser.Controller/Library/ISimilarItemsProvider.cs b/MediaBrowser.Controller/Library/ISimilarItemsProvider.cs new file mode 100644 index 0000000000..0d089369a8 --- /dev/null +++ b/MediaBrowser.Controller/Library/ISimilarItemsProvider.cs @@ -0,0 +1,26 @@ +using System; +using MediaBrowser.Model.Configuration; + +namespace MediaBrowser.Controller.Library; + +/// +/// Base marker interface for similar items providers. +/// +public interface ISimilarItemsProvider +{ + /// + /// Gets the name of the provider. + /// + string Name { get; } + + /// + /// Gets the type of the provider. + /// + MetadataPluginType Type { get; } + + /// + /// Gets the cache duration for results from this provider. + /// If null, results will not be cached. + /// + TimeSpan? CacheDuration => null; +} diff --git a/MediaBrowser.Controller/Library/SimilarItemReference.cs b/MediaBrowser.Controller/Library/SimilarItemReference.cs new file mode 100644 index 0000000000..2a40c93bdd --- /dev/null +++ b/MediaBrowser.Controller/Library/SimilarItemReference.cs @@ -0,0 +1,22 @@ +namespace MediaBrowser.Controller.Library; + +/// +/// A reference to a similar item by provider ID with a similarity score. +/// +public class SimilarItemReference +{ + /// + /// Gets or sets the provider name (e.g., "Tmdb", "MusicBrainzArtist"). + /// + public required string ProviderName { get; set; } + + /// + /// Gets or sets the provider ID value. + /// + public required string ProviderId { get; set; } + + /// + /// Gets or sets the similarity score (0.0 to 1.0). + /// + public float? Score { get; set; } +} diff --git a/MediaBrowser.Controller/Library/SimilarItemsQuery.cs b/MediaBrowser.Controller/Library/SimilarItemsQuery.cs new file mode 100644 index 0000000000..1ed3ceec16 --- /dev/null +++ b/MediaBrowser.Controller/Library/SimilarItemsQuery.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Dto; + +namespace MediaBrowser.Controller.Library; + +/// +/// Query options for similar items requests. +/// +public class SimilarItemsQuery +{ + /// + /// Gets or sets the user context. + /// + public User? User { get; set; } + + /// + /// Gets or sets the maximum number of results. + /// + public int? Limit { get; set; } + + /// + /// Gets or sets the DTO options. + /// + public DtoOptions? DtoOptions { get; set; } + + /// + /// Gets or sets the item IDs to exclude from results. + /// + public IReadOnlyList ExcludeItemIds { get; set; } = []; + + /// + /// Gets or sets the artist IDs to exclude from results. + /// + public IReadOnlyList ExcludeArtistIds { get; set; } = []; +} 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 @@ -143,6 +143,17 @@ namespace MediaBrowser.Controller.Providers IEnumerable> GetMetadataProviders(BaseItem item, LibraryOptions libraryOptions) where T : BaseItem; + /// + /// Gets the metadata providers for the provided item. + /// + /// The item. + /// The library options. + /// Whether to include disabled providers. + /// The type of metadata provider. + /// The metadata providers. + IEnumerable> GetMetadataProviders(BaseItem item, LibraryOptions libraryOptions, bool includeDisabled) + where T : BaseItem; + /// /// Gets the metadata savers for the provided item. /// 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(); - MetadataFetcherOrder = Array.Empty(); - ImageFetchers = Array.Empty(); - ImageFetcherOrder = Array.Empty(); - ImageOptions = Array.Empty(); + 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 _imageSaveLock = new(o => { o.PoolSize = 20; @@ -101,6 +105,7 @@ namespace MediaBrowser.Providers.Manager /// The lyric manager. /// The memory cache. /// The media segment manager. + /// The similar items manager. 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(); + pluginList.AddRange(similarItemsProviders.Select(i => new MetadataPlugin + { + Name = i.Name, + Type = i.Type + })); + summary.Plugins = pluginList.ToArray(); var supportedImageTypes = imageProviders.OfType() 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 @@ + + 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; + +/// +/// Client for the ListenBrainz Labs API. +/// +public class ListenBrainzLabsClient +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + private readonly Lock _rateLimitLock = new(); + + private DateTime _lastRequestTime = DateTime.MinValue; + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP client factory. + /// The logger. + public ListenBrainzLabsClient( + IHttpClientFactory httpClientFactory, + ILogger logger) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + /// + /// Gets similar artists for the given MusicBrainz artist ID. + /// + /// The MusicBrainz artist ID. + /// The cancellation token. + /// A list of similar artist MusicBrainz IDs ordered by similarity score. + public async Task> 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>(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; + +/// +/// A similar artist data entry from the ListenBrainz Labs API. +/// +public class SimilarArtistData +{ + /// + /// Gets or sets the MusicBrainz artist ID. + /// + [JsonPropertyName("artist_mbid")] + public Guid ArtistMbid { get; set; } + + /// + /// Gets or sets the artist name. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Gets or sets the similarity score. + /// + [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; + +/// +/// Response from ListenBrainz Labs similar-artists endpoint. +/// +public class SimilarArtistsResponse +{ + /// + /// Gets or sets the list of similar artists. + /// + [JsonPropertyName("data")] + public IReadOnlyList? 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; + +/// +/// ListenBrainz plugin configuration. +/// +public class PluginConfiguration : BasePluginConfiguration +{ + /// + /// The default Labs API server URL. + /// + public const string DefaultLabsServer = "https://labs.api.listenbrainz.org"; + + /// + /// The default rate limit in seconds. + /// + public const double DefaultRateLimit = 1.0; + + private string _labsServer = DefaultLabsServer; + private double _rateLimit = DefaultRateLimit; + + /// + /// Gets or sets the Labs API server URL. + /// + public string LabsServer + { + get => _labsServer; + set => _labsServer = string.IsNullOrWhiteSpace(value) ? DefaultLabsServer : value.TrimEnd('/'); + } + + /// + /// Gets or sets the similarity algorithm. + /// + public SimilarityAlgorithm Algorithm { get; set; } = SimilarityAlgorithm.SessionBased1825Days; + + /// + /// Gets or sets the rate limit in seconds. + /// + public double RateLimit + { + get => _rateLimit; + set + { + if (value < DefaultRateLimit && _labsServer == DefaultLabsServer) + { + _rateLimit = DefaultRateLimit; + } + else + { + _rateLimit = value; + } + } + } + + /// + /// Gets the algorithm string for the API call. + /// + 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; + +/// +/// Available similarity algorithms for ListenBrainz Labs API. +/// +public enum SimilarityAlgorithm +{ + /// + /// Session-based algorithm analyzing ~5 years of listening data. + /// + SessionBased1825Days = 0, + + /// + /// Session-based algorithm analyzing ~5 years of listening data (alternate). + /// + SessionBased1800Days = 1, + + /// + /// Session-based algorithm analyzing ~20 years of listening data. + /// + SessionBased7500Days = 2, + + /// + /// Session-based algorithm analyzing ~20 years with higher contribution threshold. + /// + SessionBased7500DaysHighContribution = 3, + + /// + /// Session-based algorithm analyzing ~25 years of listening data. + /// + SessionBased9000Days = 4, + + /// + /// Session-based algorithm analyzing ~75 days of recent listening data. + /// + 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; + +/// +/// Extension methods for . +/// +public static class SimilarityAlgorithmExtensions +{ + /// + /// Gets the API string value for the algorithm. + /// + /// The algorithm. + /// The API string value. + 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 @@ + + + + ListenBrainz + + +
+
+
+

ListenBrainz

+

Get similar artist recommendations from ListenBrainz Labs.

+
+
+ +
The ListenBrainz Labs API server URL. Default: https://labs.api.listenbrainz.org
+
+
+ + +
The algorithm used for artist similarity calculation.
+
+
+ +
Span of time between requests in seconds. The official server is rate limited to one request per second.
+
+
+
+ +
+
+
+
+ +
+ + 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; + +/// +/// ListenBrainz plugin instance. +/// +public class ListenBrainzPlugin : BasePlugin, IHasWebPages +{ + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + public ListenBrainzPlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + } + + /// + /// Gets the current plugin instance. + /// + public static ListenBrainzPlugin? Instance { get; private set; } + + /// + public override Guid Id => new("a5b2e8c1-9d4f-4a3b-8c7e-6f1a2b3c4d5e"); + + /// + public override string Name => "ListenBrainz"; + + /// + public override string Description => "Get similar artist recommendations from ListenBrainz Labs."; + + /// + public override string ConfigurationFileName => "Jellyfin.Plugin.ListenBrainz.xml"; + + /// + public IEnumerable 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; + +/// +/// ListenBrainz-based similar items provider for music artists. +/// +public class ListenBrainzSimilarArtistProvider : IRemoteSimilarItemsProvider +{ + private readonly ListenBrainzLabsClient _labsClient; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The ListenBrainz Labs API client. + /// The logger. + public ListenBrainzSimilarArtistProvider( + ListenBrainzLabsClient labsClient, + ILogger logger) + { + _labsClient = labsClient; + _logger = logger; + } + + /// + public string Name => "ListenBrainz"; + + /// + public MetadataPluginType Type => MetadataPluginType.SimilarityProvider; + + /// + public TimeSpan? CacheDuration => TimeSpan.FromDays(14); + + /// + public async IAsyncEnumerable 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 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; + +/// +/// TMDb-based similar items provider for movies. +/// +public class TmdbMovieSimilarProvider : IRemoteSimilarItemsProvider +{ + private readonly TmdbClientManager _tmdbClientManager; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The TMDb client manager. + /// The logger. + public TmdbMovieSimilarProvider(TmdbClientManager tmdbClientManager, ILogger logger) + { + _tmdbClientManager = tmdbClientManager; + _logger = logger; + } + + /// + public string Name => TmdbUtils.ProviderName; + + /// + public MetadataPluginType Type => MetadataPluginType.SimilarityProvider; + + /// + public TimeSpan? CacheDuration => TimeSpan.FromDays(7); + + /// + public async IAsyncEnumerable 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 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; + +/// +/// TMDb-based similar items provider for TV series. +/// +public class TmdbSeriesSimilarProvider : IRemoteSimilarItemsProvider +{ + private readonly TmdbClientManager _tmdbClientManager; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The TMDb client manager. + /// The logger. + public TmdbSeriesSimilarProvider(TmdbClientManager tmdbClientManager, ILogger logger) + { + _tmdbClientManager = tmdbClientManager; + _logger = logger; + } + + /// + public string Name => TmdbUtils.ProviderName; + + /// + public MetadataPluginType Type => MetadataPluginType.SimilarityProvider; + + /// + public TimeSpan? CacheDuration => TimeSpan.FromDays(7); + + /// + public async IAsyncEnumerable 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 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 @@ -504,6 +504,54 @@ namespace MediaBrowser.Providers.Plugins.Tmdb return searchResults?.Results; } + /// + /// Gets a single page of similar movies for a movie from the TMDb API. + /// + /// The TMDb id of the movie. + /// The page number to fetch (1-based). + /// The language for results. + /// The cancellation token. + /// A tuple containing the list of similar movies and the total number of pages available. + public async Task<(IReadOnlyList 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); + } + + /// + /// Gets a single page of similar TV shows for a series from the TMDb API. + /// + /// The TMDb id of the TV show. + /// The page number to fetch (1-based). + /// The language for results. + /// The cancellation token. + /// A tuple containing the list of similar TV shows and the total number of pages available. + public async Task<(IReadOnlyList 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); + } + /// /// Handles bad path checking and builds the absolute url. /// 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(), Mock.Of(), - Mock.Of()); + Mock.Of(), + Mock.Of()); return providerManager; } -- cgit v1.2.3