From 4ebce3907062ade1937440628eebd665440b338d Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 3 May 2026 23:43:01 +0200 Subject: [PATCH 01/46] Implement Similarity providers --- .../ApplicationHost.cs | 14 + .../SimilarItems/AudioSimilarItemsProvider.cs | 55 +++ .../LiveTvProgramSimilarItemsProvider.cs | 94 ++++ .../SimilarItems/MovieSimilarItemsProvider.cs | 79 ++++ .../MusicAlbumSimilarItemsProvider.cs | 55 +++ .../MusicArtistSimilarItemsProvider.cs | 55 +++ .../SeriesSimilarItemsProvider.cs | 54 +++ .../SimilarItems/SimilarItemsManager.cs | 423 ++++++++++++++++++ Jellyfin.Api/Controllers/LibraryController.cs | 78 ++-- .../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 +- .../Configuration/TypeOptions.cs | 16 +- .../Manager/ProviderManager.cs | 17 +- .../MediaBrowser.Providers.csproj | 2 + .../Api/ListenBrainzLabsClient.cs | 104 +++++ .../Api/Models/SimilarArtistData.cs | 28 ++ .../Api/Models/SimilarArtistsResponse.cs | 16 + .../Configuration/PluginConfiguration.cs | 60 +++ .../Configuration/SimilarityAlgorithm.cs | 37 ++ .../SimilarityAlgorithmExtensions.cs | 23 + .../ListenBrainz/Configuration/config.html | 87 ++++ .../ListenBrainz/ListenBrainzPlugin.cs | 53 +++ .../ListenBrainzSimilarArtistProvider.cs | 82 ++++ .../Tmdb/Movies/TmdbMovieSimilarProvider.cs | 89 ++++ .../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; } From 2b7f64116309c7a33611334c1d08745c6c50d537 Mon Sep 17 00:00:00 2001 From: TheMelmacian <76712303+TheMelmacian@users.noreply.github.com> Date: Sun, 10 May 2026 11:10:56 +0200 Subject: [PATCH 02/46] feat: language filters for subtitles and audio --- Jellyfin.Api/Controllers/ItemsController.cs | 39 +++++++++++++++++++ .../Controllers/TrailersController.cs | 6 +++ .../Item/BaseItemRepository.Querying.cs | 18 ++++++++- .../Item/BaseItemRepository.TranslateQuery.cs | 20 ++++++++++ .../Entities/InternalItemsQuery.cs | 6 +++ .../Querying/QueryFiltersLegacy.cs | 6 +++ .../DescendantQueryHelper.cs | 4 +- .../MatchCriteria/HasMediaStreamType.cs | 23 +++++++++-- 8 files changed, 117 insertions(+), 5 deletions(-) diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index 53656186c8..a813109c96 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -157,6 +157,8 @@ public class ItemsController : BaseJellyfinApiController /// Optional filter by items whose name is equally or lesser than a given input string. /// Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited. /// Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on audio language. This allows multiple, comma delimited values. + /// Optional. If specified, results will be filtered based on subtitale language. This allows multiple, comma delimited values. /// Optional. Enable the total record count. /// Optional, include image information in output. /// A with the items. @@ -247,6 +249,8 @@ public class ItemsController : BaseJellyfinApiController [FromQuery] string? nameLessThan, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] studioIds, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] genreIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] audioLanguages, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] subtitleLanguages, [FromQuery] bool enableTotalRecordCount = true, [FromQuery] bool? enableImages = true) { @@ -399,6 +403,8 @@ public class ItemsController : BaseJellyfinApiController MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(), MinPremiereDate = minPremiereDate?.ToUniversalTime(), MaxPremiereDate = maxPremiereDate?.ToUniversalTime(), + AudioLanguages = audioLanguages, + SubtitleLanguages = subtitleLanguages, }; if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm)) @@ -406,6 +412,33 @@ public class ItemsController : BaseJellyfinApiController query.CollapseBoxSetItems = false; } + if (query.SubtitleLanguages.Count > 0 && query.HasSubtitles.HasValue) + { + if (query.HasSubtitles.Value is true) + { + // if we check for specific subtitles we don't need a separate check for subtitle existence + query.HasSubtitles = null; + } + else + { + // if we want to know if an item has no subtitles we don't need to check for subtitles of a specific language + query.SubtitleLanguages = []; + } + } + + // for filter values that rely on media streams, we need to include alternative and linked versions + if (query.HasSubtitles.HasValue + || query.SubtitleLanguages.Count > 0 + || query.AudioLanguages.Count > 0 + || query.Is3D.HasValue + || query.IsHD.HasValue + || query.Is4K.HasValue + || query.VideoTypes.Length > 0 + ) + { + query.IncludeOwnedItems = true; + } + query.ApplyFilters(filters); // Filter by Series Status @@ -607,6 +640,8 @@ public class ItemsController : BaseJellyfinApiController /// Optional filter by items whose name is equally or lesser than a given input string. /// Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited. /// Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on audio language. This allows multiple, comma delimited values. + /// Optional. If specified, results will be filtered based on subtitale language. This allows multiple, comma delimited values. /// Optional. Enable the total record count. /// Optional, include image information in output. /// A with the items. @@ -698,6 +733,8 @@ public class ItemsController : BaseJellyfinApiController [FromQuery] string? nameLessThan, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] studioIds, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] genreIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] audioLanguages, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] subtitleLanguages, [FromQuery] bool enableTotalRecordCount = true, [FromQuery] bool? enableImages = true) => await GetItems( @@ -785,6 +822,8 @@ public class ItemsController : BaseJellyfinApiController nameLessThan, studioIds, genreIds, + audioLanguages, + subtitleLanguages, enableTotalRecordCount, enableImages).ConfigureAwait(false); diff --git a/Jellyfin.Api/Controllers/TrailersController.cs b/Jellyfin.Api/Controllers/TrailersController.cs index e2075c2b8d..121db66858 100644 --- a/Jellyfin.Api/Controllers/TrailersController.cs +++ b/Jellyfin.Api/Controllers/TrailersController.cs @@ -115,6 +115,8 @@ public class TrailersController : BaseJellyfinApiController /// Optional filter by items whose name is equally or lesser than a given input string. /// Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited. /// Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited. + /// Optional. If specified, results will be filtered based on audio language. This allows multiple, comma delimited values. + /// Optional. If specified, results will be filtered based on subtitale language. This allows multiple, comma delimited values. /// Optional. Enable the total record count. /// Optional, include image information in output. /// A with the trailers. @@ -203,6 +205,8 @@ public class TrailersController : BaseJellyfinApiController [FromQuery] string? nameLessThan, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] studioIds, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] genreIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] audioLanguages, + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] subtitleLanguages, [FromQuery] bool enableTotalRecordCount = true, [FromQuery] bool? enableImages = true) { @@ -294,6 +298,8 @@ public class TrailersController : BaseJellyfinApiController nameLessThan, studioIds, genreIds, + audioLanguages, + subtitleLanguages, enableTotalRecordCount, enableImages).ConfigureAwait(false); } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs index dc16c3b1b3..d8fc87ec18 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs @@ -517,6 +517,20 @@ public sealed partial class BaseItemRepository .OrderBy(r => r) .ToArray(); + var subtitleLanguages = context.MediaStreamInfos + .Where(s => s.StreamType == MediaStreamTypeEntity.Subtitle) + .Select(s => string.IsNullOrEmpty(s.Language) ? "und" : s.Language) // und = undetermined + .Distinct() + .OrderBy(l => l) + .ToArray(); + + var audioLanguages = context.MediaStreamInfos + .Where(s => s.StreamType == MediaStreamTypeEntity.Audio) + .Select(s => string.IsNullOrEmpty(s.Language) ? "und" : s.Language) // und = undetermined + .Distinct() + .OrderBy(l => l) + .ToArray(); + var tags = context.ItemValuesMap .Where(ivm => ivm.ItemValue.Type == ItemValueType.Tags) .Where(ivm => matchingItemIds.Contains(ivm.ItemId)) @@ -540,7 +554,9 @@ public sealed partial class BaseItemRepository Years = years, OfficialRatings = officialRatings, Tags = tags, - Genres = genres + Genres = genres, + SubtitleLanguages = subtitleLanguages, + AudioLanguages = audioLanguages }; } } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index 0abe981af8..95c4d04adc 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -823,6 +823,26 @@ public sealed partial class BaseItemRepository } } + if (filter.SubtitleLanguages.Count > 0) + { + var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, filter.SubtitleLanguages)); + baseQuery = baseQuery + .Where(e => + (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle + && (filter.SubtitleLanguages.Contains(f.Language) || (filter.SubtitleLanguages.Contains("und") && string.IsNullOrEmpty(f.Language))))) + || (e.IsFolder && foldersWithSubtitles.Contains(e.Id))); + } + + if (filter.AudioLanguages.Count > 0) + { + var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Audio, filter.AudioLanguages)); + baseQuery = baseQuery + .Where(e => + (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio + && (filter.AudioLanguages.Contains(f.Language) || (filter.AudioLanguages.Contains("und") && string.IsNullOrEmpty(f.Language))))) + || (e.IsFolder && foldersWithAudio.Contains(e.Id))); + } + if (filter.HasChapterImages.HasValue) { var hasChapterImages = filter.HasChapterImages.Value; diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs index fa82ea8663..e520ffd179 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -58,6 +58,8 @@ namespace MediaBrowser.Controller.Entities VideoTypes = []; Years = []; SkipDeserialization = false; + AudioLanguages = []; + SubtitleLanguages = []; } public InternalItemsQuery(User? user) @@ -385,6 +387,10 @@ namespace MediaBrowser.Controller.Entities public bool IncludeExtras { get; set; } + public IReadOnlyList AudioLanguages { get; set; } + + public IReadOnlyList SubtitleLanguages { get; set; } + public void SetUser(User user) { var maxRating = user.MaxParentalRatingScore; diff --git a/MediaBrowser.Model/Querying/QueryFiltersLegacy.cs b/MediaBrowser.Model/Querying/QueryFiltersLegacy.cs index fcb450ed30..aa1ca85cad 100644 --- a/MediaBrowser.Model/Querying/QueryFiltersLegacy.cs +++ b/MediaBrowser.Model/Querying/QueryFiltersLegacy.cs @@ -13,6 +13,8 @@ namespace MediaBrowser.Model.Querying Tags = Array.Empty(); OfficialRatings = Array.Empty(); Years = Array.Empty(); + AudioLanguages = Array.Empty(); + SubtitleLanguages = Array.Empty(); } public string[] Genres { get; set; } @@ -22,5 +24,9 @@ namespace MediaBrowser.Model.Querying public string[] OfficialRatings { get; set; } public int[] Years { get; set; } + + public string[] AudioLanguages { get; set; } + + public string[] SubtitleLanguages { get; set; } } } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs index 43e6a8bc00..88a2c684ff 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs @@ -111,7 +111,9 @@ public static class DescendantQueryHelper private static HashSet GetMatchingMediaStreamItemIds(JellyfinDbContext context, HasMediaStreamType criteria) { var query = context.MediaStreamInfos - .Where(ms => ms.StreamType == criteria.StreamType && ms.Language == criteria.Language); + .Where(ms => ms.StreamType == criteria.StreamType + && (criteria.Language.Contains(ms.Language) + || (criteria.Language.Contains("und") && string.IsNullOrEmpty(ms.Language)))); // und = undetermined if (criteria.IsExternal.HasValue) { diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/MatchCriteria/HasMediaStreamType.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/MatchCriteria/HasMediaStreamType.cs index 68f2ca2786..c1f6ab16a9 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/MatchCriteria/HasMediaStreamType.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/MatchCriteria/HasMediaStreamType.cs @@ -1,3 +1,6 @@ +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + +using System.Collections.Generic; using Jellyfin.Database.Implementations.Entities; namespace Jellyfin.Database.Implementations.MatchCriteria; @@ -6,9 +9,23 @@ namespace Jellyfin.Database.Implementations.MatchCriteria; /// Matches folders containing descendants with a specific media stream type and language. /// /// The type of media stream to match (Audio, Subtitle, etc.). -/// The language to match. +/// List of languages to match. /// If not null, filters by internal (false) or external (true) streams. Only applicable to subtitles. public sealed record HasMediaStreamType( MediaStreamTypeEntity StreamType, - string Language, - bool? IsExternal = null) : FolderMatchCriteria; + IReadOnlyCollection Language, + bool? IsExternal = null) : FolderMatchCriteria +{ + /// + /// Initializes a new instance of the class. + /// + /// The type of media stream to match (Audio, Subtitle, etc.). + /// The language to match. + /// If not null, filters by internal (false) or external (true) streams. Only applicable to subtitles. + public HasMediaStreamType( + MediaStreamTypeEntity StreamType, + string Language, + bool? IsExternal = null) : this(StreamType, [Language], IsExternal) + { + } +} From a42956c18286e253e4d5dc3c64e39f47490b2b4f Mon Sep 17 00:00:00 2001 From: TheMelmacian <76712303+TheMelmacian@users.noreply.github.com> Date: Sun, 10 May 2026 11:32:37 +0200 Subject: [PATCH 03/46] fix: filter for VideoTypes if Item is Iso file --- .../Item/BaseItemRepository.TranslateQuery.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index 95c4d04adc..b58e7fffe3 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -1076,8 +1076,11 @@ public sealed partial class BaseItemRepository if (filter.VideoTypes.Length > 0) { + // Dvds and Blu-rays can either be stored in a folder structure or as an iso file + // => to find all matches we need to check both: VideoType and IsoType var videoTypeBs = filter.VideoTypes.Select(vt => $"\"VideoType\":\"{vt}\"").ToArray(); - Expression> hasVideoType = e => videoTypeBs.Any(f => e.Data!.Contains(f)); + var isoTypeBs = filter.VideoTypes.Select(vt => $"\"IsoType\":\"{vt}\"").ToArray(); + Expression> hasVideoType = e => videoTypeBs.Any(f => e.Data!.Contains(f)) || isoTypeBs.Any(f => e.Data!.Contains(f)); baseQuery = baseQuery.WhereItemOrDescendantMatches(context, hasVideoType); } From 5701cdce684dbbcdfdd5cc4c79586fe623e9f2d0 Mon Sep 17 00:00:00 2001 From: TheMelmacian <76712303+TheMelmacian@users.noreply.github.com> Date: Sun, 10 May 2026 21:40:41 +0200 Subject: [PATCH 04/46] fix: prevent language filters to load in non video libraries --- .../Item/BaseItemRepository.Querying.cs | 46 +++++++++++++------ 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs index d8fc87ec18..71b46b3cb5 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs @@ -517,20 +517,6 @@ public sealed partial class BaseItemRepository .OrderBy(r => r) .ToArray(); - var subtitleLanguages = context.MediaStreamInfos - .Where(s => s.StreamType == MediaStreamTypeEntity.Subtitle) - .Select(s => string.IsNullOrEmpty(s.Language) ? "und" : s.Language) // und = undetermined - .Distinct() - .OrderBy(l => l) - .ToArray(); - - var audioLanguages = context.MediaStreamInfos - .Where(s => s.StreamType == MediaStreamTypeEntity.Audio) - .Select(s => string.IsNullOrEmpty(s.Language) ? "und" : s.Language) // und = undetermined - .Distinct() - .OrderBy(l => l) - .ToArray(); - var tags = context.ItemValuesMap .Where(ivm => ivm.ItemValue.Type == ItemValueType.Tags) .Where(ivm => matchingItemIds.Contains(ivm.ItemId)) @@ -549,6 +535,28 @@ public sealed partial class BaseItemRepository .OrderBy(g => g) .ToArray(); + // At the moment language filters are only available for video types (Movie and Series libraries). + // They are fetched directly from the MediaStreamInfos table and only filtered by StreamType. + // This is the fastest and most perfomant way to get the list of available languages, + // but the filter values can include language tags that are not linked to any item in the current library. + var subtitleLanguages = IncludesVideoTypes(filter) + ? context.MediaStreamInfos + .Where(s => s.StreamType == MediaStreamTypeEntity.Subtitle) + .Select(s => string.IsNullOrEmpty(s.Language) ? "und" : s.Language) // und = undetermined + .Distinct() + .OrderBy(l => l) + .ToArray() + : []; + + var audioLanguages = IncludesVideoTypes(filter) + ? context.MediaStreamInfos + .Where(s => s.StreamType == MediaStreamTypeEntity.Audio) + .Select(s => string.IsNullOrEmpty(s.Language) ? "und" : s.Language) // und = undetermined + .Distinct() + .OrderBy(l => l) + .ToArray() + : []; + return new QueryFiltersLegacy { Years = years, @@ -559,4 +567,14 @@ public sealed partial class BaseItemRepository AudioLanguages = audioLanguages }; } + + private bool IncludesVideoTypes(InternalItemsQuery filter) + { + return filter.IncludeItemTypes.Contains(BaseItemKind.Movie) + || filter.IncludeItemTypes.Contains(BaseItemKind.Video) + || filter.IncludeItemTypes.Contains(BaseItemKind.Series) + || filter.IncludeItemTypes.Contains(BaseItemKind.Season) + || filter.IncludeItemTypes.Contains(BaseItemKind.Episode) + || filter.IncludeItemTypes.Contains(BaseItemKind.Trailer); + } } From d5bb7756f1fb656de5ab53253008212557667399 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Mon, 11 May 2026 16:41:22 +0200 Subject: [PATCH 05/46] Implement multiple versions for episodes. --- Emby.Naming/Video/VideoListResolver.cs | 190 +++-- .../ApplicationHost.cs | 2 + .../Library/Resolvers/Movies/MovieResolver.cs | 15 +- .../Video/MultiVersionTests.cs | 675 ++++++++++++++++-- .../Video/VideoListResolverTests.cs | 106 ++- .../Library/MovieResolverTests.cs | 59 +- 6 files changed, 867 insertions(+), 180 deletions(-) diff --git a/Emby.Naming/Video/VideoListResolver.cs b/Emby.Naming/Video/VideoListResolver.cs index a4bfb8d4a1..99a73c224d 100644 --- a/Emby.Naming/Video/VideoListResolver.cs +++ b/Emby.Naming/Video/VideoListResolver.cs @@ -5,7 +5,8 @@ using System.IO; using System.Linq; using System.Text.RegularExpressions; using Emby.Naming.Common; -using Jellyfin.Extensions; +using Emby.Naming.TV; +using Jellyfin.Data.Enums; using MediaBrowser.Model.IO; namespace Emby.Naming.Video @@ -13,8 +14,21 @@ namespace Emby.Naming.Video /// /// Resolves alternative versions and extras from list of video files. /// - public static partial class VideoListResolver + public partial class VideoListResolver { + private readonly NamingOptions _namingOptions; + private readonly EpisodePathParser _episodePathParser; + + /// + /// Initializes a new instance of the class. + /// + /// The naming options. + public VideoListResolver(NamingOptions namingOptions) + { + _namingOptions = namingOptions; + _episodePathParser = new EpisodePathParser(namingOptions); + } + [GeneratedRegex("[0-9]{2}[0-9]+[ip]", RegexOptions.IgnoreCase)] private static partial Regex ResolutionRegex(); @@ -25,12 +39,12 @@ namespace Emby.Naming.Video /// Resolves alternative versions and extras from list of video files. /// /// List of related video files. - /// The naming options. /// Indication we should consider multi-versions of content. /// Whether to parse the name or use the filename. /// Top-level folder for the containing library. + /// The type of the containing collection, if known. /// Returns enumerable of which groups files together when related. - public static IReadOnlyList Resolve(IReadOnlyList videoInfos, NamingOptions namingOptions, bool supportMultiVersion = true, bool parseName = true, string? libraryRoot = "") + public IReadOnlyList Resolve(IReadOnlyList videoInfos, bool supportMultiVersion = true, bool parseName = true, string? libraryRoot = "", CollectionType? collectionType = null) { // Filter out all extras, otherwise they could cause stacks to not be resolved // See the unit test TestStackedWithTrailer @@ -38,7 +52,7 @@ namespace Emby.Naming.Video .Where(i => i.ExtraType is null) .Select(i => new FileSystemMetadata { FullName = i.Path, IsDirectory = i.IsDirectory }); - var stackResult = StackResolver.Resolve(nonExtras, namingOptions).ToList(); + var stackResult = StackResolver.Resolve(nonExtras, _namingOptions).ToList(); var remainingFiles = new List(); var standaloneMedia = new List(); @@ -67,7 +81,7 @@ namespace Emby.Naming.Video { var info = new VideoInfo(stack.Name) { - Files = stack.Files.Select(i => VideoResolver.Resolve(i, stack.IsDirectoryStack, namingOptions, parseName, libraryRoot)) + Files = stack.Files.Select(i => VideoResolver.Resolve(i, stack.IsDirectoryStack, _namingOptions, parseName, libraryRoot)) .OfType() .ToList() }; @@ -86,7 +100,9 @@ namespace Emby.Naming.Video if (supportMultiVersion) { - list = GetVideosGroupedByVersion(list, namingOptions); + list = collectionType is CollectionType.tvshows + ? GetEpisodesGroupedByVersion(list) + : GetVideosGroupedByVersion(list); } // Whatever files are left, just add them @@ -100,7 +116,7 @@ namespace Emby.Naming.Video return list; } - private static List GetVideosGroupedByVersion(List videos, NamingOptions namingOptions) + private List GetVideosGroupedByVersion(List videos) { if (videos.Count == 0) { @@ -124,7 +140,7 @@ namespace Emby.Naming.Video continue; } - if (!IsEligibleForMultiVersion(folderName, video.Files[0].FileNameWithoutExtension, namingOptions)) + if (!IsEligibleForMultiVersion(folderName, video.Files[0].FileNameWithoutExtension)) { return videos; } @@ -135,45 +151,9 @@ namespace Emby.Naming.Video } } - if (videos.Count > 1) - { - var groups = videos - .Select(x => (filename: x.Files[0].FileNameWithoutExtension.ToString(), value: x)) - .Select(x => (x.filename, resolutionMatch: ResolutionRegex().Match(x.filename), x.value)) - .GroupBy(x => x.resolutionMatch.Success) - .ToList(); + var organized = OrganizeAlternateVersions(videos, primary, folderName.ToString()); - videos.Clear(); - - StringComparer comparer = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering); - foreach (var group in groups) - { - if (group.Key) - { - videos.InsertRange(0, group - .OrderByDescending(x => x.resolutionMatch.Value, comparer) - .ThenBy(x => x.filename, comparer) - .Select(x => x.value)); - } - else - { - videos.AddRange(group.OrderBy(x => x.filename, comparer).Select(x => x.value)); - } - } - } - - primary ??= videos[0]; - videos.Remove(primary); - - var list = new List - { - primary - }; - - list[0].AlternateVersions = videos.Select(x => x.Files[0]).ToArray(); - list[0].Name = folderName.ToString(); - - return list; + return [organized]; } private static bool HaveSameYear(IReadOnlyList videos) @@ -195,7 +175,7 @@ namespace Emby.Naming.Video return true; } - private static bool IsEligibleForMultiVersion(ReadOnlySpan folderName, ReadOnlySpan testFilename, NamingOptions namingOptions) + private bool IsEligibleForMultiVersion(ReadOnlySpan folderName, ReadOnlySpan testFilename) { if (!testFilename.StartsWith(folderName, StringComparison.OrdinalIgnoreCase)) { @@ -209,7 +189,7 @@ namespace Emby.Naming.Video } // There are no span overloads for regex unfortunately - if (CleanStringParser.TryClean(testFilename.ToString(), namingOptions.CleanStringRegexes, out var cleanName)) + if (CleanStringParser.TryClean(testFilename.ToString(), _namingOptions.CleanStringRegexes, out var cleanName)) { testFilename = cleanName.AsSpan().Trim(); } @@ -221,5 +201,117 @@ namespace Emby.Naming.Video || testFilename[0] == '.' || CheckMultiVersionRegex().IsMatch(testFilename); } + + private List GetEpisodesGroupedByVersion(List videos) + { + if (videos.Count < 2) + { + return videos; + } + + var result = new List(); + var groups = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + for (var i = 0; i < videos.Count; i++) + { + var video = videos[i]; + if (video.ExtraType is not null) + { + result.Add(video); + continue; + } + + var episodeResult = _episodePathParser.Parse(video.Files[0].Path, false); + string? key = null; + if (episodeResult.Success) + { + if (episodeResult.IsByDate + && episodeResult.Year.HasValue + && episodeResult.Month.HasValue + && episodeResult.Day.HasValue) + { + key = FormattableString.Invariant( + $"D{episodeResult.Year.Value}{episodeResult.Month.Value:D2}{episodeResult.Day.Value:D2}"); + } + else if (episodeResult.EpisodeNumber.HasValue) + { + key = FormattableString.Invariant( + $"S{episodeResult.SeasonNumber ?? 0}E{episodeResult.EpisodeNumber.Value}"); + } + } + + if (key is null) + { + result.Add(video); + continue; + } + + if (!groups.TryGetValue(key, out var group)) + { + group = []; + groups[key] = group; + } + + group.Add(video); + } + + foreach (var group in groups.Values) + { + if (group.Count == 1) + { + result.Add(group[0]); + continue; + } + + result.Add(OrganizeAlternateVersions(group)); + } + + return result; + } + + private static VideoInfo OrganizeAlternateVersions( + List videos, + VideoInfo? primaryOverride = null, + string? nameOverride = null) + { + if (videos.Count > 1) + { + var groups = videos + .Select(x => (filename: x.Files[0].FileNameWithoutExtension.ToString(), value: x)) + .Select(x => (x.filename, resolutionMatch: ResolutionRegex().Match(x.filename), x.value)) + .GroupBy(x => x.resolutionMatch.Success) + .ToList(); + + videos = []; + + StringComparer comparer = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering); + foreach (var group in groups) + { + if (group.Key) + { + videos.InsertRange(0, group + .OrderByDescending(x => x.resolutionMatch.Value, comparer) + .ThenBy(x => x.filename, comparer) + .Select(x => x.value)); + } + else + { + videos.AddRange(group.OrderBy(x => x.filename, comparer).Select(x => x.value)); + } + } + } + + var primary = primaryOverride ?? videos[0]; + videos.Remove(primary); + + primary.AlternateVersions = [.. videos.Select(x => x.Files[0])]; + + if (nameOverride is not null) + { + primary.Name = nameOverride; + } + + return primary; + } } } diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 3e98a5276c..00b029f18c 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -14,6 +14,7 @@ using System.Reflection; using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; using Emby.Naming.Common; +using Emby.Naming.Video; using Emby.Photos; using Emby.Server.Implementations.Chapters; using Emby.Server.Implementations.Collections; @@ -530,6 +531,7 @@ namespace Emby.Server.Implementations serviceCollection.AddTransient(provider => new Lazy(provider.GetRequiredService)); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs index 98e8f5350b..8750e15ca6 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs @@ -28,15 +28,16 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies public partial class MovieResolver : BaseVideoResolver