diff options
68 files changed, 3354 insertions, 300 deletions
diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index 7b1d8b4132..b14e5958ab 100644 --- a/.github/workflows/ci-codeql-analysis.yml +++ b/.github/workflows/ci-codeql-analysis.yml @@ -32,13 +32,13 @@ jobs: dotnet-version: '10.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 diff --git a/Emby.Naming/Video/VideoInfo.cs b/Emby.Naming/Video/VideoInfo.cs index 8847ee9bc9..028b639122 100644 --- a/Emby.Naming/Video/VideoInfo.cs +++ b/Emby.Naming/Video/VideoInfo.cs @@ -17,8 +17,8 @@ namespace Emby.Naming.Video { Name = name; - Files = Array.Empty<VideoFileInfo>(); - AlternateVersions = Array.Empty<VideoFileInfo>(); + Files = []; + AlternateVersions = []; } /// <summary> @@ -40,10 +40,10 @@ namespace Emby.Naming.Video public IReadOnlyList<VideoFileInfo> Files { get; set; } /// <summary> - /// Gets or sets the alternate versions. + /// Gets or sets the alternate versions. Each alternate may itself span multiple files. /// </summary> /// <value>The alternate versions.</value> - public IReadOnlyList<VideoFileInfo> AlternateVersions { get; set; } + public IReadOnlyList<VideoInfo> AlternateVersions { get; set; } /// <summary> /// Gets or sets the extra type. diff --git a/Emby.Naming/Video/VideoListResolver.cs b/Emby.Naming/Video/VideoListResolver.cs index a4bfb8d4a1..29330b132d 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,23 @@ namespace Emby.Naming.Video /// <summary> /// Resolves alternative versions and extras from list of video files. /// </summary> - public static partial class VideoListResolver + public partial class VideoListResolver { + private static readonly StringComparer _numericOrdinalComparer = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering); + + private readonly NamingOptions _namingOptions; + private readonly EpisodePathParser _episodePathParser; + + /// <summary> + /// Initializes a new instance of the <see cref="VideoListResolver"/> class. + /// </summary> + /// <param name="namingOptions">The naming options.</param> + 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 +41,12 @@ namespace Emby.Naming.Video /// Resolves alternative versions and extras from list of video files. /// </summary> /// <param name="videoInfos">List of related video files.</param> - /// <param name="namingOptions">The naming options.</param> /// <param name="supportMultiVersion">Indication we should consider multi-versions of content.</param> /// <param name="parseName">Whether to parse the name or use the filename.</param> /// <param name="libraryRoot">Top-level folder for the containing library.</param> + /// <param name="collectionType">The type of the containing collection, if known.</param> /// <returns>Returns enumerable of <see cref="VideoInfo"/> which groups files together when related.</returns> - public static IReadOnlyList<VideoInfo> Resolve(IReadOnlyList<VideoFileInfo> videoInfos, NamingOptions namingOptions, bool supportMultiVersion = true, bool parseName = true, string? libraryRoot = "") + public IReadOnlyList<VideoInfo> Resolve(IReadOnlyList<VideoFileInfo> 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 +54,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<VideoFileInfo>(); var standaloneMedia = new List<VideoFileInfo>(); @@ -67,7 +83,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<VideoFileInfo>() .ToList() }; @@ -86,7 +102,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 +118,7 @@ namespace Emby.Naming.Video return list; } - private static List<VideoInfo> GetVideosGroupedByVersion(List<VideoInfo> videos, NamingOptions namingOptions) + private List<VideoInfo> GetVideosGroupedByVersion(List<VideoInfo> videos) { if (videos.Count == 0) { @@ -124,7 +142,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 +153,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(); - - videos.Clear(); + var organized = OrganizeAlternateVersions(videos, primary, folderName.ToString()); - 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<VideoInfo> - { - 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<VideoInfo> videos) @@ -195,7 +177,7 @@ namespace Emby.Naming.Video return true; } - private static bool IsEligibleForMultiVersion(ReadOnlySpan<char> folderName, ReadOnlySpan<char> testFilename, NamingOptions namingOptions) + private bool IsEligibleForMultiVersion(ReadOnlySpan<char> folderName, ReadOnlySpan<char> testFilename) { if (!testFilename.StartsWith(folderName, StringComparison.OrdinalIgnoreCase)) { @@ -209,7 +191,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 +203,113 @@ namespace Emby.Naming.Video || testFilename[0] == '.' || CheckMultiVersionRegex().IsMatch(testFilename); } + + private List<VideoInfo> GetEpisodesGroupedByVersion(List<VideoInfo> videos) + { + if (videos.Count < 2) + { + return videos; + } + + var result = new List<VideoInfo>(); + var groups = new Dictionary<string, List<VideoInfo>>(StringComparer.OrdinalIgnoreCase); + + for (var i = 0; i < videos.Count; i++) + { + var video = videos[i]; + 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<VideoInfo> 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 = []; + + foreach (var group in groups) + { + if (group.Key) + { + videos.InsertRange(0, group + .OrderByDescending(x => x.resolutionMatch.Value, _numericOrdinalComparer) + .ThenBy(x => x.filename, _numericOrdinalComparer) + .Select(x => x.value)); + } + else + { + videos.AddRange(group.OrderBy(x => x.filename, _numericOrdinalComparer).Select(x => x.value)); + } + } + } + + // Prefer a stacked entry (more than one part) as primary + var primary = primaryOverride + ?? videos.FirstOrDefault(v => v.Files.Count > 1) + ?? videos[0]; + videos.Remove(primary); + + primary.AlternateVersions = videos; + + 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..c81829688f 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; @@ -25,6 +26,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 +94,11 @@ using MediaBrowser.Model.System; using MediaBrowser.Model.Tasks; using MediaBrowser.Providers.Lyric; using MediaBrowser.Providers.Manager; +using MediaBrowser.Providers.Plugins.ListenBrainz; +using MediaBrowser.Providers.Plugins.ListenBrainz.Api; using MediaBrowser.Providers.Plugins.Tmdb; +using MediaBrowser.Providers.Plugins.Tmdb.Movies; +using MediaBrowser.Providers.Plugins.Tmdb.TV; using MediaBrowser.Providers.Subtitles; using MediaBrowser.XbmcMetadata.Providers; using Microsoft.AspNetCore.Http; @@ -483,6 +489,11 @@ namespace Emby.Server.Implementations serviceCollection.AddScoped<ISystemManager, SystemManager>(); serviceCollection.AddSingleton<TmdbClientManager>(); + serviceCollection.AddSingleton<TmdbMovieSimilarProvider>(); + serviceCollection.AddSingleton<TmdbSeriesSimilarProvider>(); + + serviceCollection.AddSingleton<ListenBrainzLabsClient>(); + serviceCollection.AddSingleton<ListenBrainzSimilarArtistProvider>(); serviceCollection.AddSingleton(NetManager); @@ -530,12 +541,15 @@ namespace Emby.Server.Implementations serviceCollection.AddTransient(provider => new Lazy<IUserViewManager>(provider.GetRequiredService<IUserViewManager>)); serviceCollection.AddSingleton<ILibraryManager, LibraryManager>(); serviceCollection.AddSingleton<NamingOptions>(); + serviceCollection.AddSingleton<VideoListResolver>(); serviceCollection.AddSingleton<IMusicManager, MusicManager>(); serviceCollection.AddSingleton<ILibraryMonitor, LibraryMonitor>(); serviceCollection.AddSingleton<DotIgnoreIgnoreRule>(); + serviceCollection.AddSingleton<ISimilarItemsManager, SimilarItemsManager>(); + serviceCollection.AddSingleton<ISearchEngine, SearchEngine>(); serviceCollection.AddSingleton<IWebSocketManager, WebSocketManager>(); @@ -693,6 +707,8 @@ namespace Emby.Server.Implementations GetExports<IExternalUrlProvider>()); Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>()); + + Resolve<ISimilarItemsManager>().AddParts(GetExports<ISimilarItemsProvider>()); } /// <summary> diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 11f1496086..30ff1bd333 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -13,6 +13,7 @@ using System.Threading.Tasks; using BitFaster.Caching.Lru; using Emby.Naming.Common; using Emby.Naming.TV; +using Emby.Naming.Video; using Emby.Server.Implementations.Library.Resolvers; using Emby.Server.Implementations.Library.Validators; using Emby.Server.Implementations.Playlists; @@ -87,6 +88,7 @@ namespace Emby.Server.Implementations.Library private readonly IPathManager _pathManager; private readonly FastConcurrentLru<Guid, BaseItem> _cache; private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule; + private readonly IMediaStreamRepository _mediaStreamRepository; /// <summary> /// The _root folder sync lock. @@ -129,6 +131,7 @@ namespace Emby.Server.Implementations.Library /// <param name="peopleRepository">The people repository.</param> /// <param name="pathManager">The path manager.</param> /// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param> + /// <param name="mediaStreamRepository">The media stream repository.</param> public LibraryManager( IServerApplicationHost appHost, ILoggerFactory loggerFactory, @@ -151,7 +154,8 @@ namespace Emby.Server.Implementations.Library IDirectoryService directoryService, IPeopleRepository peopleRepository, IPathManager pathManager, - DotIgnoreIgnoreRule dotIgnoreIgnoreRule) + DotIgnoreIgnoreRule dotIgnoreIgnoreRule, + IMediaStreamRepository mediaStreamRepository) { _appHost = appHost; _logger = loggerFactory.CreateLogger<LibraryManager>(); @@ -181,6 +185,8 @@ namespace Emby.Server.Implementations.Library _configurationManager.ConfigurationUpdated += ConfigurationUpdated; + _mediaStreamRepository = mediaStreamRepository; + RecordConfigurationValues(_configurationManager.Configuration); } @@ -787,6 +793,42 @@ namespace Emby.Server.Implementations.Library CollectionType? collectionType = null) => ResolvePath(fileInfo, directoryService ?? new DirectoryService(_fileSystem), null, parent, collectionType); + private void SetAdditionalPartsFromStack(Video altVideo, string path) + { + if (altVideo.AdditionalParts is { Length: > 0 }) + { + return; + } + + var directory = Path.GetDirectoryName(path); + if (string.IsNullOrEmpty(directory)) + { + return; + } + + IEnumerable<FileSystemMetadata> siblings; + try + { + siblings = _fileSystem.GetFiles(directory); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to enumerate siblings to detect stack for {Path}", path); + return; + } + + var stacks = StackResolver.Resolve(siblings, _namingOptions); + foreach (var stack in stacks) + { + if (stack.Files.Count > 1 + && string.Equals(stack.Files[0], path, StringComparison.OrdinalIgnoreCase)) + { + altVideo.AdditionalParts = stack.Files.Skip(1).ToArray(); + return; + } + } + } + /// <inheritdoc /> public Video? ResolveAlternateVersion(string path, Type expectedVideoType, Folder? parent, CollectionType? collectionType) { @@ -2307,6 +2349,10 @@ namespace Emby.Server.Implementations.Library { altVideo.OwnerId = video.Id; altVideo.SetPrimaryVersionId(video.Id); + // ResolveAlternateVersion only sees the alternate's primary file. + // If the alternate is itself a stack (e.g. 1080p part1 + part2), + // detect its parts from sibling files so its AdditionalParts persist. + SetAdditionalPartsFromStack(altVideo, path); allItems.Add(altVideo); } } @@ -2510,6 +2556,10 @@ namespace Emby.Server.Implementations.Library { altVideo.OwnerId = video.Id; altVideo.SetPrimaryVersionId(video.Id); + // ResolveAlternateVersion only sees the alternate's primary file. + // If the alternate is itself a stack (e.g. 1080p part1 + part2), + // detect its parts from sibling files so its AdditionalParts persist. + SetAdditionalPartsFromStack(altVideo, path); allItems.Add(altVideo); } } @@ -3800,5 +3850,11 @@ namespace Emby.Server.Implementations.Library SetTopParentOrAncestorIds(query); return _itemRepository.GetQueryFiltersLegacy(query); } + + /// <inheritdoc /> + public IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType) + { + return _mediaStreamRepository.GetMediaStreamLanguages(mediaStreamType); + } } } diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs index 98e8f5350b..68b66ab7f5 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<Video>, IMultiItemResolver { private readonly IImageProcessor _imageProcessor; + private readonly VideoListResolver _videoListResolver; - private static readonly CollectionType[] _validCollectionTypes = new[] - { + private static readonly CollectionType[] _validCollectionTypes = + [ CollectionType.movies, CollectionType.homevideos, CollectionType.musicvideos, CollectionType.tvshows, CollectionType.photos - }; + ]; /// <summary> /// Initializes a new instance of the <see cref="MovieResolver"/> class. @@ -45,10 +46,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies /// <param name="logger">The logger.</param> /// <param name="namingOptions">The naming options.</param> /// <param name="directoryService">The directory service.</param> - public MovieResolver(IImageProcessor imageProcessor, ILogger<MovieResolver> logger, NamingOptions namingOptions, IDirectoryService directoryService) + /// <param name="videoListResolver">The video list resolver.</param> + public MovieResolver(IImageProcessor imageProcessor, ILogger<MovieResolver> logger, NamingOptions namingOptions, IDirectoryService directoryService, VideoListResolver videoListResolver) : base(logger, namingOptions, directoryService) { _imageProcessor = imageProcessor; + _videoListResolver = videoListResolver; } /// <summary> @@ -228,7 +231,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies if (collectionType == CollectionType.tvshows) { - return ResolveVideos<Episode>(parent, files, false, collectionType, true); + return ResolveVideos<Episode>(parent, files, true, collectionType, true); } return null; @@ -274,7 +277,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies .Where(f => f is not null) .ToList(); - var resolverResult = VideoListResolver.Resolve(videoInfos, NamingOptions, supportMultiEditions, parseName, parent.ContainingFolderPath); + var resolverResult = _videoListResolver.Resolve(videoInfos, supportMultiEditions, parseName, parent.ContainingFolderPath, collectionType); var result = new MultiItemResolverResult { @@ -302,7 +305,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies ProductionYear = video.Year, Name = parseName ? video.Name : firstVideo.Name, AdditionalParts = additionalParts, - LocalAlternateVersions = video.AlternateVersions.Select(i => i.Path).ToArray() + LocalAlternateVersions = video.AlternateVersions.Select(av => av.Files[0].Path).ToArray() }; SetVideoType(videoItem, firstVideo); @@ -331,9 +334,13 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies for (var j = 0; j < current.AlternateVersions.Count; j++) { - if (ContainsFile(current.AlternateVersions[j], file)) + var alternate = current.AlternateVersions[j]; + for (var k = 0; k < alternate.Files.Count; k++) { - return true; + if (ContainsFile(alternate.Files[k], file)) + { + return true; + } } } } diff --git a/Emby.Server.Implementations/Library/SimilarItems/AudioSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/AudioSimilarItemsProvider.cs new file mode 100644 index 0000000000..1cc670b8ee --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/AudioSimilarItemsProvider.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Provides similar items for audio tracks. +/// </summary> +public class AudioSimilarItemsProvider : ILocalSimilarItemsProvider<Audio> +{ + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="AudioSimilarItemsProvider"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + public AudioSimilarItemsProvider(ILibraryManager libraryManager) + { + _libraryManager = libraryManager; + } + + /// <inheritdoc/> + public string Name => "Local Genre/Tag"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider; + + /// <inheritdoc/> + public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(Audio item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + var internalQuery = new InternalItemsQuery(query.User) + { + Genres = item.Genres, + Tags = item.Tags, + Limit = query.Limit, + DtoOptions = query.DtoOptions ?? new DtoOptions(), + ExcludeItemIds = [.. query.ExcludeItemIds], + ExcludeArtistIds = [.. query.ExcludeArtistIds], + IncludeItemTypes = [BaseItemKind.Audio], + EnableGroupByMetadataKey = false, + EnableTotalRecordCount = true, + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] + }; + + return Task.FromResult(_libraryManager.GetItemList(internalQuery)); + } +} diff --git a/Emby.Server.Implementations/Library/SimilarItems/LiveTvProgramSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/LiveTvProgramSimilarItemsProvider.cs new file mode 100644 index 0000000000..7665ee2f79 --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/LiveTvProgramSimilarItemsProvider.cs @@ -0,0 +1,94 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Configuration; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Provides similar items for Live TV programs. +/// </summary> +public class LiveTvProgramSimilarItemsProvider : ILocalSimilarItemsProvider<LiveTvProgram> +{ + private readonly ILibraryManager _libraryManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="LiveTvProgramSimilarItemsProvider"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + /// <param name="serverConfigurationManager">The server configuration manager.</param> + public LiveTvProgramSimilarItemsProvider( + ILibraryManager libraryManager, + IServerConfigurationManager serverConfigurationManager) + { + _libraryManager = libraryManager; + _serverConfigurationManager = serverConfigurationManager; + } + + /// <inheritdoc/> + public string Name => "Local Genre/Tag"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider; + + /// <inheritdoc/> + public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(LiveTvProgram item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + BaseItemKind[] includeItemTypes; + bool enableGroupByMetadataKey; + bool enableTotalRecordCount; + + if (item.IsMovie) + { + // Movie-like program + var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; + + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + { + itemTypes.Add(BaseItemKind.Trailer); + itemTypes.Add(BaseItemKind.LiveTvProgram); + } + + includeItemTypes = [.. itemTypes]; + enableGroupByMetadataKey = true; + enableTotalRecordCount = false; + } + else if (item.IsSeries) + { + // Series-like program + includeItemTypes = [BaseItemKind.Series]; + enableGroupByMetadataKey = false; + enableTotalRecordCount = true; + } + else + { + // Default - match same type + includeItemTypes = [item.GetBaseItemKind()]; + enableGroupByMetadataKey = false; + enableTotalRecordCount = true; + } + + var internalQuery = new InternalItemsQuery(query.User) + { + Genres = item.Genres, + Tags = item.Tags, + Limit = query.Limit, + DtoOptions = query.DtoOptions ?? new DtoOptions(), + ExcludeItemIds = [.. query.ExcludeItemIds], + IncludeItemTypes = includeItemTypes, + EnableGroupByMetadataKey = enableGroupByMetadataKey, + EnableTotalRecordCount = enableTotalRecordCount, + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] + }; + + return Task.FromResult(_libraryManager.GetItemList(internalQuery)); + } +} diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs new file mode 100644 index 0000000000..93aa0574c0 --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Provides similar items for movies and trailers. +/// </summary> +public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie>, ILocalSimilarItemsProvider<Trailer> +{ + private readonly ILibraryManager _libraryManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="MovieSimilarItemsProvider"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + /// <param name="serverConfigurationManager">The server configuration manager.</param> + public MovieSimilarItemsProvider( + ILibraryManager libraryManager, + IServerConfigurationManager serverConfigurationManager) + { + _libraryManager = libraryManager; + _serverConfigurationManager = serverConfigurationManager; + } + + /// <inheritdoc/> + public string Name => "Local Genre/Tag"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider; + + /// <inheritdoc/> + public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(Movie item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + return Task.FromResult(GetSimilarMovieItems(item, query)); + } + + /// <inheritdoc/> + public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(Trailer item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + return Task.FromResult(GetSimilarMovieItems(item, query)); + } + + bool ILocalSimilarItemsProvider.Supports(Type itemType) + => typeof(Movie).IsAssignableFrom(itemType) || typeof(Trailer).IsAssignableFrom(itemType); + + Task<IReadOnlyList<BaseItem>> ILocalSimilarItemsProvider.GetSimilarItemsAsync(BaseItem item, SimilarItemsQuery query, CancellationToken cancellationToken) + => item switch + { + Movie movie => GetSimilarItemsAsync(movie, query, cancellationToken), + Trailer trailer => GetSimilarItemsAsync(trailer, query, cancellationToken), + _ => throw new ArgumentException($"Unsupported item type {item.GetType()}", nameof(item)) + }; + + private IReadOnlyList<BaseItem> GetSimilarMovieItems(BaseItem item, SimilarItemsQuery query) + { + var includeItemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; + + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + { + includeItemTypes.Add(BaseItemKind.Trailer); + includeItemTypes.Add(BaseItemKind.LiveTvProgram); + } + + var internalQuery = new InternalItemsQuery(query.User) + { + Genres = item.Genres, + Tags = item.Tags, + Limit = query.Limit, + DtoOptions = query.DtoOptions ?? new DtoOptions(), + ExcludeItemIds = [.. query.ExcludeItemIds], + IncludeItemTypes = [.. includeItemTypes], + EnableGroupByMetadataKey = true, + EnableTotalRecordCount = false, + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] + }; + + return _libraryManager.GetItemList(internalQuery); + } +} diff --git a/Emby.Server.Implementations/Library/SimilarItems/MusicAlbumSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MusicAlbumSimilarItemsProvider.cs new file mode 100644 index 0000000000..c13045deda --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/MusicAlbumSimilarItemsProvider.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Provides similar items for music albums. +/// </summary> +public class MusicAlbumSimilarItemsProvider : ILocalSimilarItemsProvider<MusicAlbum> +{ + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="MusicAlbumSimilarItemsProvider"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + public MusicAlbumSimilarItemsProvider(ILibraryManager libraryManager) + { + _libraryManager = libraryManager; + } + + /// <inheritdoc/> + public string Name => "Local Genre/Tag"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider; + + /// <inheritdoc/> + public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(MusicAlbum item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + var internalQuery = new InternalItemsQuery(query.User) + { + Genres = item.Genres, + Tags = item.Tags, + Limit = query.Limit, + DtoOptions = query.DtoOptions ?? new DtoOptions(), + ExcludeItemIds = [.. query.ExcludeItemIds], + ExcludeArtistIds = [.. query.ExcludeArtistIds], + IncludeItemTypes = [BaseItemKind.MusicAlbum], + EnableGroupByMetadataKey = false, + EnableTotalRecordCount = true, + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] + }; + + return Task.FromResult(_libraryManager.GetItemList(internalQuery)); + } +} diff --git a/Emby.Server.Implementations/Library/SimilarItems/MusicArtistSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MusicArtistSimilarItemsProvider.cs new file mode 100644 index 0000000000..3331419442 --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/MusicArtistSimilarItemsProvider.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Provides similar items for music artists. +/// </summary> +public class MusicArtistSimilarItemsProvider : ILocalSimilarItemsProvider<MusicArtist> +{ + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="MusicArtistSimilarItemsProvider"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + public MusicArtistSimilarItemsProvider(ILibraryManager libraryManager) + { + _libraryManager = libraryManager; + } + + /// <inheritdoc/> + public string Name => "Local Genre/Tag"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider; + + /// <inheritdoc/> + public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(MusicArtist item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + var internalQuery = new InternalItemsQuery(query.User) + { + Genres = item.Genres, + Tags = item.Tags, + Limit = query.Limit, + DtoOptions = query.DtoOptions ?? new DtoOptions(), + ExcludeItemIds = [.. query.ExcludeItemIds], + ExcludeArtistIds = [.. query.ExcludeArtistIds], + IncludeItemTypes = [BaseItemKind.MusicArtist], + EnableGroupByMetadataKey = false, + EnableTotalRecordCount = true, + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] + }; + + return Task.FromResult(_libraryManager.GetItemList(internalQuery)); + } +} diff --git a/Emby.Server.Implementations/Library/SimilarItems/SeriesSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/SeriesSimilarItemsProvider.cs new file mode 100644 index 0000000000..0366fb752e --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/SeriesSimilarItemsProvider.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Provides similar items for TV series. +/// </summary> +public class SeriesSimilarItemsProvider : ILocalSimilarItemsProvider<Series> +{ + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="SeriesSimilarItemsProvider"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + public SeriesSimilarItemsProvider(ILibraryManager libraryManager) + { + _libraryManager = libraryManager; + } + + /// <inheritdoc/> + public string Name => "Local Genre/Tag"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider; + + /// <inheritdoc/> + public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(Series item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + var internalQuery = new InternalItemsQuery(query.User) + { + Genres = item.Genres, + Tags = item.Tags, + Limit = query.Limit, + DtoOptions = query.DtoOptions ?? new DtoOptions(), + ExcludeItemIds = [.. query.ExcludeItemIds], + IncludeItemTypes = [BaseItemKind.Series], + EnableGroupByMetadataKey = false, + EnableTotalRecordCount = true, + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] + }; + + return Task.FromResult(_libraryManager.GetItemList(internalQuery)); + } +} diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs new file mode 100644 index 0000000000..b56779cf3f --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs @@ -0,0 +1,406 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions.Json; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Querying; +using Microsoft.Extensions.Logging; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Manages similar items providers and orchestrates similar items operations. +/// </summary> +public class SimilarItemsManager : ISimilarItemsManager +{ + private readonly ILogger<SimilarItemsManager> _logger; + private readonly IServerApplicationPaths _appPaths; + private readonly ILibraryManager _libraryManager; + private readonly IFileSystem _fileSystem; + private ISimilarItemsProvider[] _similarItemsProviders = []; + + /// <summary> + /// Initializes a new instance of the <see cref="SimilarItemsManager"/> class. + /// </summary> + /// <param name="logger">The logger.</param> + /// <param name="appPaths">The server application paths.</param> + /// <param name="libraryManager">The library manager.</param> + /// <param name="fileSystem">The file system.</param> + public SimilarItemsManager( + ILogger<SimilarItemsManager> logger, + IServerApplicationPaths appPaths, + ILibraryManager libraryManager, + IFileSystem fileSystem) + { + _logger = logger; + _appPaths = appPaths; + _libraryManager = libraryManager; + _fileSystem = fileSystem; + } + + /// <inheritdoc/> + public void AddParts(IEnumerable<ISimilarItemsProvider> providers) + { + _similarItemsProviders = providers.ToArray(); + } + + /// <inheritdoc/> + public IReadOnlyList<ISimilarItemsProvider> GetSimilarItemsProviders<T>() + where T : BaseItem + { + var itemType = typeof(T); + return _similarItemsProviders + .Where(p => (p is ILocalSimilarItemsProvider local && local.Supports(itemType)) + || (p is IRemoteSimilarItemsProvider remote && remote.Supports(itemType))) + .ToList(); + } + + /// <inheritdoc/> + public async Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync( + BaseItem item, + IReadOnlyList<Guid> excludeArtistIds, + User? user, + DtoOptions dtoOptions, + int? limit, + LibraryOptions? libraryOptions, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(item); + ArgumentNullException.ThrowIfNull(excludeArtistIds); + + var itemType = item.GetType(); + var requestedLimit = limit ?? 50; + var itemKind = item.GetBaseItemKind(); + + // Ensure ProviderIds is included in DtoOptions for matching remote provider responses + if (!dtoOptions.Fields.Contains(ItemFields.ProviderIds)) + { + dtoOptions.Fields = dtoOptions.Fields.Concat([ItemFields.ProviderIds]).ToArray(); + } + + // Local providers are always enabled. Remote providers must be explicitly enabled. + var localProviders = _similarItemsProviders + .OfType<ILocalSimilarItemsProvider>() + .Where(p => p.Supports(itemType)) + .ToList(); + var remoteProviders = _similarItemsProviders + .OfType<IRemoteSimilarItemsProvider>() + .Where(p => p.Supports(itemType)); + var matchingProviders = new List<ISimilarItemsProvider>(localProviders); + + var typeOptions = libraryOptions?.GetTypeOptions(itemType.Name); + if (typeOptions?.SimilarItemProviders?.Length > 0) + { + matchingProviders.AddRange(remoteProviders + .Where(p => typeOptions.SimilarItemProviders.Contains(p.Name, StringComparer.OrdinalIgnoreCase))); + } + + var orderConfig = typeOptions?.SimilarItemProviderOrder is { Length: > 0 } order + ? order + : typeOptions?.SimilarItemProviders; + var orderedProviders = matchingProviders + .OrderBy(p => GetConfiguredSimilarProviderOrder(orderConfig, p.Name)) + .ToList(); + + var allResults = new List<(BaseItem Item, float Score)>(); + var excludeIds = new HashSet<Guid> { item.Id }; + foreach (var (providerOrder, provider) in orderedProviders.Index()) + { + if (allResults.Count >= requestedLimit || cancellationToken.IsCancellationRequested) + { + break; + } + + try + { + if (provider is ILocalSimilarItemsProvider localProvider) + { + var query = new SimilarItemsQuery + { + User = user, + Limit = requestedLimit - allResults.Count, + DtoOptions = dtoOptions, + ExcludeItemIds = [.. excludeIds], + ExcludeArtistIds = excludeArtistIds + }; + + var items = await localProvider.GetSimilarItemsAsync(item, query, cancellationToken).ConfigureAwait(false); + + foreach (var (position, resultItem) in items.Index()) + { + if (excludeIds.Add(resultItem.Id)) + { + var score = CalculateScore(null, providerOrder, position); + allResults.Add((resultItem, score)); + } + } + } + else if (provider is IRemoteSimilarItemsProvider remoteProvider) + { + var cachePath = GetSimilarItemsCachePath(provider.Name, itemType.Name, item.Id); + + var cachedReferences = await TryReadSimilarItemsCacheAsync(cachePath, cancellationToken).ConfigureAwait(false); + if (cachedReferences is not null) + { + var resolvedItems = ResolveRemoteReferences(cachedReferences, providerOrder, user, dtoOptions, itemKind, excludeIds); + allResults.AddRange(resolvedItems); + continue; + } + + var query = new SimilarItemsQuery + { + User = user, + Limit = requestedLimit - allResults.Count, + DtoOptions = dtoOptions, + ExcludeItemIds = [.. excludeIds], + ExcludeArtistIds = excludeArtistIds + }; + + // Collect references in batches and resolve against local library. + // Stop fetching once we have enough resolved local items. + const int BatchSize = 20; + var remaining = requestedLimit - allResults.Count; + var collectedReferences = new List<SimilarItemReference>(); + var pendingBatch = new List<SimilarItemReference>(); + + await foreach (var reference in remoteProvider.GetSimilarItemsAsync(item, query, cancellationToken).ConfigureAwait(false)) + { + collectedReferences.Add(reference); + pendingBatch.Add(reference); + + if (pendingBatch.Count >= BatchSize) + { + var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds); + allResults.AddRange(resolvedItems); + remaining -= resolvedItems.Count; + pendingBatch.Clear(); + + if (remaining <= 0) + { + break; + } + } + } + + // Resolve any remaining references in the last partial batch + if (pendingBatch.Count > 0) + { + var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds); + allResults.AddRange(resolvedItems); + } + + if (collectedReferences.Count > 0 && provider.CacheDuration is not null) + { + await SaveSimilarItemsCacheAsync(cachePath, collectedReferences, provider.CacheDuration.Value, cancellationToken).ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Similar items provider {ProviderName} failed for item {ItemId}", provider.Name, item.Id); + } + } + + return allResults + .OrderByDescending(x => x.Score) + .Select(x => x.Item) + .Take(requestedLimit) + .ToList(); + } + + private List<(BaseItem Item, float Score)> ResolveRemoteReferences( + IReadOnlyList<SimilarItemReference> references, + int providerOrder, + User? user, + DtoOptions dtoOptions, + BaseItemKind itemKind, + HashSet<Guid> excludeIds) + { + if (references.Count == 0) + { + return []; + } + + var resolvedById = new Dictionary<Guid, (BaseItem Item, float Score)>(); + var providerLookup = new Dictionary<(string ProviderName, string ProviderId), (float? Score, int Position)>(StringTupleComparer.Instance); + + foreach (var (position, match) in references.Index()) + { + var lookupKey = (match.ProviderName, match.ProviderId); + if (!providerLookup.TryGetValue(lookupKey, out var existing)) + { + providerLookup[lookupKey] = (match.Score, position); + } + else if (match.Score > existing.Score || (match.Score == existing.Score && position < existing.Position)) + { + providerLookup[lookupKey] = (match.Score, position); + } + } + + var allProviderIds = providerLookup + .GroupBy(kvp => kvp.Key.ProviderName) + .ToDictionary(g => g.Key, g => g.Select(x => x.Key.ProviderId).ToArray()); + + var query = new InternalItemsQuery(user) + { + HasAnyProviderIds = allProviderIds, + IncludeItemTypes = [itemKind], + DtoOptions = dtoOptions + }; + + var items = _libraryManager.GetItemList(query); + + foreach (var item in items) + { + if (excludeIds.Contains(item.Id) || resolvedById.ContainsKey(item.Id)) + { + continue; + } + + foreach (var providerName in allProviderIds.Keys) + { + if (item.TryGetProviderId(providerName, out var itemProviderId) && providerLookup.TryGetValue((providerName, itemProviderId), out var matchInfo)) + { + var score = CalculateScore(matchInfo.Score, providerOrder, matchInfo.Position); + if (!resolvedById.TryGetValue(item.Id, out var existing) || existing.Score < score) + { + excludeIds.Add(item.Id); + resolvedById[item.Id] = (item, score); + } + + break; + } + } + } + + return [.. resolvedById.Values]; + } + + private static float CalculateScore(float? matchScore, int providerOrder, int position) + { + // Use provider-supplied score if available, otherwise derive from position + var baseScore = matchScore ?? (1.0f - (position * 0.02f)); + + // Apply small boost based on provider order (higher priority providers get small bonus) + var priorityBoost = Math.Max(0, 10 - providerOrder) * 0.005f; + + return Math.Clamp(baseScore + priorityBoost, 0f, 1f); + } + + private static int GetConfiguredSimilarProviderOrder(string[]? orderConfig, string providerName) + { + if (orderConfig is null || orderConfig.Length == 0) + { + return int.MaxValue; + } + + var index = Array.FindIndex(orderConfig, name => string.Equals(name, providerName, StringComparison.OrdinalIgnoreCase)); + return index >= 0 ? index : int.MaxValue; + } + + private string GetSimilarItemsCachePath(string providerName, string baseItemType, Guid itemId) + { + var dataPath = Path.Combine( + _appPaths.CachePath, + $"{providerName.ToLowerInvariant()}-similar-{baseItemType.ToLowerInvariant()}"); + return Path.Combine(dataPath, $"{itemId.ToString("N", CultureInfo.InvariantCulture)}.json"); + } + + private async Task<List<SimilarItemReference>?> TryReadSimilarItemsCacheAsync(string cachePath, CancellationToken cancellationToken) + { + var fileInfo = _fileSystem.GetFileSystemInfo(cachePath); + if (!fileInfo.Exists || fileInfo.Length == 0) + { + return null; + } + + try + { + var stream = File.OpenRead(cachePath); + await using (stream.ConfigureAwait(false)) + { + var cache = await JsonSerializer.DeserializeAsync<SimilarItemsCache>(stream, JsonDefaults.Options, cancellationToken).ConfigureAwait(false); + if (cache?.References is not null && DateTime.UtcNow < cache.ExpiresAt) + { + return cache.References; + } + } + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Failed to read similar items cache from {CachePath}", cachePath); + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Failed to parse similar items cache from {CachePath}", cachePath); + } + + return null; + } + + private async Task SaveSimilarItemsCacheAsync(string cachePath, List<SimilarItemReference> references, TimeSpan cacheDuration, CancellationToken cancellationToken) + { + try + { + var directory = Path.GetDirectoryName(cachePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + var cache = new SimilarItemsCache + { + References = references, + ExpiresAt = DateTime.UtcNow.Add(cacheDuration) + }; + + var stream = File.Create(cachePath); + await using (stream.ConfigureAwait(false)) + { + await JsonSerializer.SerializeAsync(stream, cache, JsonDefaults.Options, cancellationToken).ConfigureAwait(false); + } + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Failed to save similar items cache to {CachePath}", cachePath); + } + } + + private sealed class SimilarItemsCache + { + public List<SimilarItemReference>? References { get; set; } + + public DateTime ExpiresAt { get; set; } + } + + private sealed class StringTupleComparer : IEqualityComparer<(string Key, string Value)> + { + public static readonly StringTupleComparer Instance = new(); + + public bool Equals((string Key, string Value) x, (string Key, string Value) y) + => string.Equals(x.Key, y.Key, StringComparison.OrdinalIgnoreCase) && + string.Equals(x.Value, y.Value, StringComparison.OrdinalIgnoreCase); + + public int GetHashCode((string Key, string Value) obj) + => HashCode.Combine( + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Key), + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Value)); + } +} diff --git a/Emby.Server.Implementations/Library/SplashscreenPostScanTask.cs b/Emby.Server.Implementations/Library/SplashscreenPostScanTask.cs index 71ce3b6012..7c605036cf 100644 --- a/Emby.Server.Implementations/Library/SplashscreenPostScanTask.cs +++ b/Emby.Server.Implementations/Library/SplashscreenPostScanTask.cs @@ -80,7 +80,7 @@ public class SplashscreenPostScanTask : ILibraryPostScanTask ImageTypes = [imageType], Limit = 30, // TODO max parental rating configurable - MaxParentalRating = new(10, null), + MaxParentalRating = new(13, null), OrderBy = [ (ItemSortBy.Random, SortOrder.Ascending) diff --git a/Emby.Server.Implementations/Localization/Core/de.json b/Emby.Server.Implementations/Localization/Core/de.json index 5ba3c7e575..8ac5fdf6fc 100644 --- a/Emby.Server.Implementations/Localization/Core/de.json +++ b/Emby.Server.Implementations/Localization/Core/de.json @@ -107,5 +107,6 @@ "TaskMoveTrickplayImagesDescription": "Trickplay-Bilder werden entsprechend der Bibliothekseinstellungen verschoben.", "CleanupUserDataTask": "Aufgabe zur Bereinigung von Benutzerdaten", "CleanupUserDataTaskDescription": "Löscht alle Benutzerdaten (Abspielstatus, Favoritenstatus, usw.) von Medien, die seit mindestens 90 Tagen nicht mehr vorhanden sind.", - "Original": "Original" + "Original": "Original", + "LyricDownloadFailureFromForItem": "Fehler beim Download der Songtexte von {0} für {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/eu.json b/Emby.Server.Implementations/Localization/Core/eu.json index 8375c96b82..71c351adcd 100644 --- a/Emby.Server.Implementations/Localization/Core/eu.json +++ b/Emby.Server.Implementations/Localization/Core/eu.json @@ -106,5 +106,7 @@ "TaskMoveTrickplayImagesDescription": "Lehendik dauden trickplay fitxategiak liburutegiaren ezarpenen arabera mugitzen dira.", "TaskAudioNormalizationDescription": "Audio normalizazio datuak lortzeko fitxategiak eskaneatzen ditu.", "CleanupUserDataTaskDescription": "Gutxienez 90 egunez dagoeneko existitzen ez den multimediatik erabiltzaile-datu guztiak (ikusteko egoera, gogokoen egoera, etab.) garbitzen ditu.", - "CleanupUserDataTask": "Erabiltzaileen datuak garbitzeko zeregina" + "CleanupUserDataTask": "Erabiltzaileen datuak garbitzeko zeregina", + "LyricDownloadFailureFromForItem": "Ezin izan dira {1}-ren letrak deskargatu {0}-tik", + "Original": "Jatorrizkoa" } diff --git a/Emby.Server.Implementations/Localization/Core/fr-CA.json b/Emby.Server.Implementations/Localization/Core/fr-CA.json index 342f18012a..e05cce47b0 100644 --- a/Emby.Server.Implementations/Localization/Core/fr-CA.json +++ b/Emby.Server.Implementations/Localization/Core/fr-CA.json @@ -106,5 +106,7 @@ "TaskMoveTrickplayImages": "Changer l'emplacement des images Trickplay", "TaskExtractMediaSegmentsDescription": "Extrait ou obtient des segments de média à partir des plugins compatibles avec MediaSegment.", "CleanupUserDataTaskDescription": "Nettoie toutes les données utilisateur (état de la montre, statut favori, etc.) des supports qui ne sont plus présents depuis au moins 90 jours.", - "CleanupUserDataTask": "Tâche de nettoyage des données utilisateur" + "CleanupUserDataTask": "Tâche de nettoyage des données utilisateur", + "LyricDownloadFailureFromForItem": "Le téléchargement des paroles a échoué de {0} pour {1}", + "Original": "Original" } diff --git a/Emby.Server.Implementations/Localization/Core/fr.json b/Emby.Server.Implementations/Localization/Core/fr.json index 272ed77485..ceba1dcb41 100644 --- a/Emby.Server.Implementations/Localization/Core/fr.json +++ b/Emby.Server.Implementations/Localization/Core/fr.json @@ -106,5 +106,7 @@ "TaskExtractMediaSegmentsDescription": "Extrait ou obtient des segments de média à partir des plugins compatibles avec MediaSegment.", "TaskMoveTrickplayImagesDescription": "Déplace les fichiers trickplay existants en fonction des paramètres de la bibliothèque.", "CleanupUserDataTaskDescription": "Nettoie toutes les données utilisateur (état de la montre, statut favori, etc.) des supports qui ne sont plus présents depuis au moins 90 jours.", - "CleanupUserDataTask": "Tâche de nettoyage des données utilisateur" + "CleanupUserDataTask": "Tâche de nettoyage des données utilisateur", + "LyricDownloadFailureFromForItem": "Le téléchargement des paroles à échoué de {0} pour {1}", + "Original": "Original" } diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json index 363ae502bd..9aea3adc22 100644 --- a/Emby.Server.Implementations/Localization/Core/nl.json +++ b/Emby.Server.Implementations/Localization/Core/nl.json @@ -107,5 +107,6 @@ "CleanupUserDataTaskDescription": "Wist alle gebruikersgegevens (kijkstatus, favorieten, etc.) van media die al minstens 90 dagen niet meer aanwezig zijn.", "CleanupUserDataTask": "Opruimtaak gebruikersdata", "Genres": "Genres", - "Original": "Oorspronkelijk" + "Original": "Oorspronkelijk", + "LyricDownloadFailureFromForItem": "Downloaden van liedteksten voor {1} van {0} mislukt" } diff --git a/Emby.Server.Implementations/Localization/Core/pl.json b/Emby.Server.Implementations/Localization/Core/pl.json index f447dc9457..c4657bdd6e 100644 --- a/Emby.Server.Implementations/Localization/Core/pl.json +++ b/Emby.Server.Implementations/Localization/Core/pl.json @@ -107,5 +107,6 @@ "TaskMoveTrickplayImagesDescription": "Przenosi istniejące pliki Trickplay zgodnie z ustawieniami biblioteki.", "CleanupUserDataTaskDescription": "Usuwa wszystkie dane użytkownika (stan oglądanych, status ulubionych itp.) z mediów, które nie są dostępne od co najmniej 90 dni.", "CleanupUserDataTask": "Zadanie czyszczenia danych użytkownika", - "Original": "Oryginalny" + "Original": "Oryginalny", + "LyricDownloadFailureFromForItem": "Błąd podczas pobierania tekstu piosenki z {0} dla {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/tr.json b/Emby.Server.Implementations/Localization/Core/tr.json index b623055ddf..0c42d4a55f 100644 --- a/Emby.Server.Implementations/Localization/Core/tr.json +++ b/Emby.Server.Implementations/Localization/Core/tr.json @@ -106,5 +106,7 @@ "TaskDownloadMissingLyricsDescription": "Şarkı sözlerini indirir", "TaskExtractMediaSegmentsDescription": "MediaSegment özelliği etkin olan eklentilerden medya segmentlerini çıkarır veya alır.", "CleanupUserDataTask": "Kullanıcı verisi temizleme görevi", - "CleanupUserDataTaskDescription": "En az 90 gün boyunca artık mevcut olmayan medyadaki tüm kullanıcı verilerini (İzleme durumu, favori durumu vb.) temizler." + "CleanupUserDataTaskDescription": "En az 90 gün boyunca artık mevcut olmayan medyadaki tüm kullanıcı verilerini (İzleme durumu, favori durumu vb.) temizler.", + "LyricDownloadFailureFromForItem": "{1} şarkı sözleri {0} adresinden indirilemedi", + "Original": "Orijinal" } diff --git a/Emby.Server.Implementations/Localization/Core/uk.json b/Emby.Server.Implementations/Localization/Core/uk.json index 3b989806e7..ccb9d915d1 100644 --- a/Emby.Server.Implementations/Localization/Core/uk.json +++ b/Emby.Server.Implementations/Localization/Core/uk.json @@ -107,5 +107,6 @@ "TaskExtractMediaSegmentsDescription": "Витягує або отримує медіа-сегменти з плагінів з підтримкою MediaSegment.", "CleanupUserDataTask": "Завдання очищення даних користувача", "CleanupUserDataTaskDescription": "Очищає всі дані користувача (стан перегляду, статус обраного тощо) з медіа, які перестали бути доступними щонайменше 90 днів тому.", - "Original": "Оригінал" + "Original": "Оригінал", + "LyricDownloadFailureFromForItem": "Не вдалося завантажити текст пісні з {0} для {1}" } diff --git a/Emby.Server.Implementations/Localization/Core/vi.json b/Emby.Server.Implementations/Localization/Core/vi.json index 2c09942102..2ba665e2ff 100644 --- a/Emby.Server.Implementations/Localization/Core/vi.json +++ b/Emby.Server.Implementations/Localization/Core/vi.json @@ -107,5 +107,6 @@ "TaskExtractMediaSegments": "Quét Phân Đoạn Phương Tiện", "CleanupUserDataTask": "Tác vụ dọn dẹp dữ liệu người dùng", "CleanupUserDataTaskDescription": "Làm sạch tất cả dữ liệu người dùng (trạng thái xem, trạng thái yêu thích, v.v.) từ phương tiện không còn có mặt trong ít nhất 90 ngày.", - "Original": "Gốc" + "Original": "Gốc", + "LyricDownloadFailureFromForItem": "Lời bài hát không tải xuống được từ {0} cho {1}" } diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs index 2f53784db1..cfc8be28ae 100644 --- a/Jellyfin.Api/Controllers/FilterController.cs +++ b/Jellyfin.Api/Controllers/FilterController.cs @@ -8,6 +8,8 @@ using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -24,16 +26,19 @@ public class FilterController : BaseJellyfinApiController { private readonly ILibraryManager _libraryManager; private readonly IUserManager _userManager; + private readonly ILocalizationManager _localization; /// <summary> /// Initializes a new instance of the <see cref="FilterController"/> class. /// </summary> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - public FilterController(ILibraryManager libraryManager, IUserManager userManager) + /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> + public FilterController(ILibraryManager libraryManager, IUserManager userManager, ILocalizationManager localization) { _libraryManager = libraryManager; _userManager = userManager; + _localization = localization; } /// <summary> @@ -183,6 +188,36 @@ public class FilterController : BaseJellyfinApiController }).ToArray(); } + if (includeItemTypes.Contains(BaseItemKind.Movie) || includeItemTypes.Contains(BaseItemKind.Series)) + { + filters.AudioLanguages = _libraryManager + .GetMediaStreamLanguages(MediaStreamType.Audio) + .Select(language => + { + var culture = _localization.FindLanguageInfo(language); + return new NameValuePair + { + Name = culture is null ? language : $"{culture.DisplayName} ({language})", + Value = language + }; + }) + .OrderBy(l => l.Name) + .ToArray(); + filters.SubtitleLanguages = _libraryManager + .GetMediaStreamLanguages(MediaStreamType.Subtitle) + .Select(language => + { + var culture = _localization.FindLanguageInfo(language); + return new NameValuePair + { + Name = culture is null ? language : $"{culture.DisplayName} ({language})", + Value = language + }; + }) + .OrderBy(l => l.Name) + .ToArray(); + } + return filters; } } diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index e6ba4e7f29..43e4737694 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -157,6 +157,8 @@ public class ItemsController : BaseJellyfinApiController /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> + /// <param name="audioLanguages">Optional. If specified, results will be filtered based on audio language. This allows multiple, comma delimited values.</param> + /// <param name="subtitleLanguages">Optional. If specified, results will be filtered based on subtitle language. This allows multiple, comma delimited values.</param> /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param> /// <param name="enableImages">Optional, include image information in output.</param> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns> @@ -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) + { + // if we check for specific subtitles we don't need a separate check for subtitle existence + query.HasSubtitles = null; + } + else + { + // if we search for items without 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 @@ -785,6 +818,8 @@ public class ItemsController : BaseJellyfinApiController nameLessThan, studioIds, genreIds, + [], + [], enableTotalRecordCount, enableImages).ConfigureAwait(false); diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index 0839d62a5c..abf27b7702 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -47,6 +47,7 @@ namespace Jellyfin.Api.Controllers; public class LibraryController : BaseJellyfinApiController { private readonly IProviderManager _providerManager; + private readonly ISimilarItemsManager _similarItemsManager; private readonly ILibraryManager _libraryManager; private readonly IUserManager _userManager; private readonly IDtoService _dtoService; @@ -60,6 +61,7 @@ public class LibraryController : BaseJellyfinApiController /// Initializes a new instance of the <see cref="LibraryController"/> class. /// </summary> /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="similarItemsManager">Instance of the <see cref="ISimilarItemsManager"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> @@ -70,6 +72,7 @@ public class LibraryController : BaseJellyfinApiController /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> public LibraryController( IProviderManager providerManager, + ISimilarItemsManager similarItemsManager, ILibraryManager libraryManager, IUserManager userManager, IDtoService dtoService, @@ -80,6 +83,7 @@ public class LibraryController : BaseJellyfinApiController IServerConfigurationManager serverConfigurationManager) { _providerManager = providerManager; + _similarItemsManager = similarItemsManager; _libraryManager = libraryManager; _userManager = userManager; _dtoService = dtoService; @@ -708,6 +712,7 @@ public class LibraryController : BaseJellyfinApiController /// <param name="userId">Optional. Filter by user id, and attach user data.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> + /// <param name="cancellationToken">The cancellation token.</param> /// <response code="200">Similar items returned.</response> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> containing the similar items.</returns> [HttpGet("Artists/{itemId}/Similar", Name = "GetSimilarArtists")] @@ -718,12 +723,13 @@ public class LibraryController : BaseJellyfinApiController [HttpGet("Trailers/{itemId}/Similar", Name = "GetSimilarTrailers")] [Authorize] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetSimilarItems( + public async Task<ActionResult<QueryResult<BaseItemDto>>> GetSimilarItems( [FromRoute, Required] Guid itemId, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] excludeArtistIds, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields) + [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields, + CancellationToken cancellationToken) { userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() @@ -746,57 +752,22 @@ public class LibraryController : BaseJellyfinApiController var dtoOptions = new DtoOptions { Fields = fields }; - var program = item as IHasProgramAttributes; - bool? isMovie = item is Movie || (program is not null && program.IsMovie) || item is Trailer; - bool? isSeries = item is Series || (program is not null && program.IsSeries); + // Get library options for provider configuration + var libraryOptions = _libraryManager.GetLibraryOptions(item); - var includeItemTypes = new List<BaseItemKind>(); - if (isMovie.Value) - { - includeItemTypes.Add(BaseItemKind.Movie); - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - includeItemTypes.Add(BaseItemKind.Trailer); - includeItemTypes.Add(BaseItemKind.LiveTvProgram); - } - } - else if (isSeries.Value) - { - includeItemTypes.Add(BaseItemKind.Series); - } - else - { - // For non series and movie types these columns are typically null - // isSeries = null; - isMovie = null; - includeItemTypes.Add(item.GetBaseItemKind()); - } - - var query = new InternalItemsQuery(user) - { - Genres = item.Genres, - Tags = item.Tags, - Limit = limit, - IncludeItemTypes = includeItemTypes.ToArray(), - DtoOptions = dtoOptions, - EnableTotalRecordCount = !isMovie ?? true, - EnableGroupByMetadataKey = isMovie ?? false, - ExcludeItemIds = [itemId], - OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] - }; - - // ExcludeArtistIds - if (excludeArtistIds.Length != 0) - { - query.ExcludeArtistIds = excludeArtistIds; - } - - var itemsResult = _libraryManager.GetItemList(query); + var itemsResult = await _similarItemsManager.GetSimilarItemsAsync( + item, + excludeArtistIds, + user, + dtoOptions, + limit, + libraryOptions, + cancellationToken).ConfigureAwait(false); var returnList = _dtoService.GetBaseItemDtos(itemsResult, dtoOptions, user); return new QueryResult<BaseItemDto>( - query.StartIndex, + 0, itemsResult.Count, returnList); } @@ -907,6 +878,17 @@ public class LibraryController : BaseJellyfinApiController .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase) .ToArray(), + SimilarItemProviders = plugins + .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) + .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.LocalSimilarityProvider || p.Type == MetadataPluginType.SimilarityProvider)) + .Select(i => new LibraryOptionInfoDto + { + Name = i.Name, + DefaultEnabled = i.Type == MetadataPluginType.LocalSimilarityProvider + }) + .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(), + SupportedImageTypes = plugins .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) .SelectMany(i => i.SupportedImageTypes ?? Array.Empty<ImageType>()) diff --git a/Jellyfin.Api/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 /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> + /// <param name="audioLanguages">Optional. If specified, results will be filtered based on audio language. This allows multiple, comma delimited values.</param> + /// <param name="subtitleLanguages">Optional. If specified, results will be filtered based on subtitale language. This allows multiple, comma delimited values.</param> /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param> /// <param name="enableImages">Optional, include image information in output.</param> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the trailers.</returns> @@ -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.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs b/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs index f76c4a9678..98da6c8f44 100644 --- a/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs +++ b/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; @@ -18,20 +17,25 @@ public class LibraryTypeOptionsDto /// <summary> /// Gets or sets the metadata fetchers. /// </summary> - public IReadOnlyList<LibraryOptionInfoDto> MetadataFetchers { get; set; } = Array.Empty<LibraryOptionInfoDto>(); + public IReadOnlyList<LibraryOptionInfoDto> MetadataFetchers { get; set; } = []; /// <summary> /// Gets or sets the image fetchers. /// </summary> - public IReadOnlyList<LibraryOptionInfoDto> ImageFetchers { get; set; } = Array.Empty<LibraryOptionInfoDto>(); + public IReadOnlyList<LibraryOptionInfoDto> ImageFetchers { get; set; } = []; + + /// <summary> + /// Gets or sets the similar item providers. + /// </summary> + public IReadOnlyList<LibraryOptionInfoDto> SimilarItemProviders { get; set; } = []; /// <summary> /// Gets or sets the supported image types. /// </summary> - public IReadOnlyList<ImageType> SupportedImageTypes { get; set; } = Array.Empty<ImageType>(); + public IReadOnlyList<ImageType> SupportedImageTypes { get; set; } = []; /// <summary> /// Gets or sets the default image options. /// </summary> - public IReadOnlyList<ImageOption> DefaultImageOptions { get; set; } = Array.Empty<ImageOption>(); + public IReadOnlyList<ImageOption> DefaultImageOptions { get; set; } = []; } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index 59e61cfd65..624b1b561c 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -824,6 +824,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; @@ -953,6 +973,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 @@ -1057,8 +1088,12 @@ 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 + // alternatively, we could provide specific IsoType filters var videoTypeBs = filter.VideoTypes.Select(vt => $"\"VideoType\":\"{vt}\"").ToArray(); - Expression<Func<BaseItemEntity, bool>> hasVideoType = e => videoTypeBs.Any(f => e.Data!.Contains(f)); + var isoTypeBs = filter.VideoTypes.Select(vt => $"\"IsoType\":\"{vt}\"").ToArray(); + Expression<Func<BaseItemEntity, bool>> hasVideoType = e => videoTypeBs.Any(f => e.Data!.Contains(f)) || isoTypeBs.Any(f => e.Data!.Contains(f)); baseQuery = baseQuery.WhereItemOrDescendantMatches(context, hasVideoType); } diff --git a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs index dd0446f49a..7fa33c8639 100644 --- a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs +++ b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs @@ -55,6 +55,17 @@ public class MediaStreamRepository : IMediaStreamRepository return TranslateQuery(context.MediaStreamInfos.AsNoTracking(), filter).AsEnumerable().Select(Map).ToArray(); } + /// <inheritdoc /> + public IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType) + { + using var context = _dbProvider.CreateDbContext(); + return context.MediaStreamInfos + .Where(e => e.StreamType == (MediaStreamTypeEntity)mediaStreamType) + .Select(s => string.IsNullOrEmpty(s.Language) ? "und" : s.Language) // und = undetermined + .Distinct() + .ToArray(); + } + private string? GetPathToSave(string? path) { if (path is null) diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs index fa82ea8663..1e5b5aa164 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) @@ -351,6 +353,8 @@ namespace MediaBrowser.Controller.Entities public Dictionary<string, string>? HasAnyProviderId { get; set; } + public Dictionary<string, string[]>? HasAnyProviderIds { get; set; } + public Guid[] AlbumArtistIds { get; set; } public Guid[] BoxSetLibraryFolders { get; set; } @@ -385,6 +389,10 @@ namespace MediaBrowser.Controller.Entities public bool IncludeExtras { get; set; } + public IReadOnlyList<string> AudioLanguages { get; set; } + + public IReadOnlyList<string> SubtitleLanguages { get; set; } + public void SetUser(User user) { var maxRating = user.MaxParentalRatingScore; diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index f5e3d7034e..f4c2196400 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -784,5 +784,12 @@ namespace MediaBrowser.Controller.Library /// <param name="query">The query filter.</param> /// <returns>Aggregated filter values.</returns> QueryFiltersLegacy GetQueryFiltersLegacy(InternalItemsQuery query); + + /// <summary> + /// Gets a list of all language codes of the provided stream type. + /// </summary> + /// <param name="mediaStreamType">The stream type.</param> + /// <returns>List of language codes.</returns> + IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType); } } diff --git a/MediaBrowser.Controller/Library/ILocalSimilarItemsProvider.cs b/MediaBrowser.Controller/Library/ILocalSimilarItemsProvider.cs new file mode 100644 index 0000000000..b8e41ec810 --- /dev/null +++ b/MediaBrowser.Controller/Library/ILocalSimilarItemsProvider.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// Provides similar items from the local library. +/// Returns fully resolved BaseItems directly - no additional resolution needed. +/// </summary> +public interface ILocalSimilarItemsProvider : ISimilarItemsProvider +{ + /// <summary> + /// Determines whether the provider can handle items of the specified type. + /// </summary> + /// <param name="itemType">The item type.</param> + /// <returns><c>true</c> if the provider handles this item type; otherwise <c>false</c>.</returns> + bool Supports(Type itemType); + + /// <summary> + /// Gets similar items from the local library. + /// </summary> + /// <param name="item">The source item to find similar items for.</param> + /// <param name="query">The query options (user, limit, exclusions, etc.).</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>The list of similar items from the library.</returns> + Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync( + BaseItem item, + SimilarItemsQuery query, + CancellationToken cancellationToken); +} + +/// <summary> +/// Provides similar items from the local library for a specific item type. +/// Returns fully resolved BaseItems directly - no additional resolution needed. +/// </summary> +/// <typeparam name="TItemType">The type of item this provider handles.</typeparam> +public interface ILocalSimilarItemsProvider<TItemType> : ILocalSimilarItemsProvider + where TItemType : BaseItem +{ + /// <summary> + /// Gets similar items from the local library. + /// </summary> + /// <param name="item">The source item to find similar items for.</param> + /// <param name="query">The query options (user, limit, exclusions, etc.).</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>The list of similar items from the library.</returns> + Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync( + TItemType item, + SimilarItemsQuery query, + CancellationToken cancellationToken); + + bool ILocalSimilarItemsProvider.Supports(Type itemType) + => typeof(TItemType).IsAssignableFrom(itemType); + + Task<IReadOnlyList<BaseItem>> ILocalSimilarItemsProvider.GetSimilarItemsAsync( + BaseItem item, + SimilarItemsQuery query, + CancellationToken cancellationToken) + => GetSimilarItemsAsync((TItemType)item, query, cancellationToken); +} diff --git a/MediaBrowser.Controller/Library/IRemoteSimilarItemsProvider.cs b/MediaBrowser.Controller/Library/IRemoteSimilarItemsProvider.cs new file mode 100644 index 0000000000..3803e51769 --- /dev/null +++ b/MediaBrowser.Controller/Library/IRemoteSimilarItemsProvider.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// Provides similar item references from remote/external sources. +/// Returns lightweight references with ProviderIds that the manager resolves to library items. +/// </summary> +public interface IRemoteSimilarItemsProvider : ISimilarItemsProvider +{ + /// <summary> + /// Determines whether the provider can handle items of the specified type. + /// </summary> + /// <param name="itemType">The item type.</param> + /// <returns><c>true</c> if the provider handles this item type; otherwise <c>false</c>.</returns> + bool Supports(Type itemType); + + /// <summary> + /// Gets similar item references from an external source as an async stream. + /// </summary> + /// <param name="item">The source item to find similar items for.</param> + /// <param name="query">The query options (user, limit, exclusions).</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>An async enumerable of similar item references.</returns> + IAsyncEnumerable<SimilarItemReference> GetSimilarItemsAsync( + BaseItem item, + SimilarItemsQuery query, + CancellationToken cancellationToken); +} + +/// <summary> +/// Provides similar item references from remote/external sources for a specific item type. +/// Returns lightweight references with ProviderIds that the manager resolves to library items. +/// </summary> +/// <typeparam name="TItemType">The type of item this provider handles.</typeparam> +public interface IRemoteSimilarItemsProvider<TItemType> : IRemoteSimilarItemsProvider + where TItemType : BaseItem +{ + /// <summary> + /// Gets similar item references from an external source as an async stream. + /// </summary> + /// <param name="item">The source item to find similar items for.</param> + /// <param name="query">The query options (user, limit, exclusions).</param> + /// <param name="cancellationToken">Cancellation token.</param> + /// <returns>An async enumerable of similar item references.</returns> + IAsyncEnumerable<SimilarItemReference> GetSimilarItemsAsync( + TItemType item, + SimilarItemsQuery query, + CancellationToken cancellationToken); + + bool IRemoteSimilarItemsProvider.Supports(Type itemType) + => typeof(TItemType).IsAssignableFrom(itemType); + + IAsyncEnumerable<SimilarItemReference> IRemoteSimilarItemsProvider.GetSimilarItemsAsync( + BaseItem item, + SimilarItemsQuery query, + CancellationToken cancellationToken) + => GetSimilarItemsAsync((TItemType)item, query, cancellationToken); +} diff --git a/MediaBrowser.Controller/Library/ISimilarItemsManager.cs b/MediaBrowser.Controller/Library/ISimilarItemsManager.cs new file mode 100644 index 0000000000..0ced6f71ee --- /dev/null +++ b/MediaBrowser.Controller/Library/ISimilarItemsManager.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Configuration; + +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// Interface for managing similar items providers and operations. +/// </summary> +public interface ISimilarItemsManager +{ + /// <summary> + /// Registers similar items providers discovered through dependency injection. + /// </summary> + /// <param name="providers">The similar items providers to register.</param> + void AddParts(IEnumerable<ISimilarItemsProvider> providers); + + /// <summary> + /// Gets the similar items providers for a specific item type. + /// </summary> + /// <typeparam name="T">The item type.</typeparam> + /// <returns>The list of similar items providers for that type.</returns> + IReadOnlyList<ISimilarItemsProvider> GetSimilarItemsProviders<T>() + where T : BaseItem; + + /// <summary> + /// Gets similar items for the specified item. + /// </summary> + /// <param name="item">The source item to find similar items for.</param> + /// <param name="excludeArtistIds">Artist IDs to exclude from results.</param> + /// <param name="user">The user context.</param> + /// <param name="dtoOptions">The DTO options.</param> + /// <param name="limit">Maximum number of results.</param> + /// <param name="libraryOptions">The library options for provider configuration.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The list of similar items.</returns> + Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync( + BaseItem item, + IReadOnlyList<Guid> excludeArtistIds, + User? user, + DtoOptions dtoOptions, + int? limit, + LibraryOptions? libraryOptions, + CancellationToken cancellationToken); +} diff --git a/MediaBrowser.Controller/Library/ISimilarItemsProvider.cs b/MediaBrowser.Controller/Library/ISimilarItemsProvider.cs new file mode 100644 index 0000000000..0d089369a8 --- /dev/null +++ b/MediaBrowser.Controller/Library/ISimilarItemsProvider.cs @@ -0,0 +1,26 @@ +using System; +using MediaBrowser.Model.Configuration; + +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// Base marker interface for similar items providers. +/// </summary> +public interface ISimilarItemsProvider +{ + /// <summary> + /// Gets the name of the provider. + /// </summary> + string Name { get; } + + /// <summary> + /// Gets the type of the provider. + /// </summary> + MetadataPluginType Type { get; } + + /// <summary> + /// Gets the cache duration for results from this provider. + /// If null, results will not be cached. + /// </summary> + TimeSpan? CacheDuration => null; +} diff --git a/MediaBrowser.Controller/Library/SimilarItemReference.cs b/MediaBrowser.Controller/Library/SimilarItemReference.cs new file mode 100644 index 0000000000..2a40c93bdd --- /dev/null +++ b/MediaBrowser.Controller/Library/SimilarItemReference.cs @@ -0,0 +1,22 @@ +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// A reference to a similar item by provider ID with a similarity score. +/// </summary> +public class SimilarItemReference +{ + /// <summary> + /// Gets or sets the provider name (e.g., "Tmdb", "MusicBrainzArtist"). + /// </summary> + public required string ProviderName { get; set; } + + /// <summary> + /// Gets or sets the provider ID value. + /// </summary> + public required string ProviderId { get; set; } + + /// <summary> + /// Gets or sets the similarity score (0.0 to 1.0). + /// </summary> + public float? Score { get; set; } +} diff --git a/MediaBrowser.Controller/Library/SimilarItemsQuery.cs b/MediaBrowser.Controller/Library/SimilarItemsQuery.cs new file mode 100644 index 0000000000..1ed3ceec16 --- /dev/null +++ b/MediaBrowser.Controller/Library/SimilarItemsQuery.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Dto; + +namespace MediaBrowser.Controller.Library; + +/// <summary> +/// Query options for similar items requests. +/// </summary> +public class SimilarItemsQuery +{ + /// <summary> + /// Gets or sets the user context. + /// </summary> + public User? User { get; set; } + + /// <summary> + /// Gets or sets the maximum number of results. + /// </summary> + public int? Limit { get; set; } + + /// <summary> + /// Gets or sets the DTO options. + /// </summary> + public DtoOptions? DtoOptions { get; set; } + + /// <summary> + /// Gets or sets the item IDs to exclude from results. + /// </summary> + public IReadOnlyList<Guid> ExcludeItemIds { get; set; } = []; + + /// <summary> + /// Gets or sets the artist IDs to exclude from results. + /// </summary> + public IReadOnlyList<Guid> ExcludeArtistIds { get; set; } = []; +} diff --git a/MediaBrowser.Controller/Persistence/IMediaStreamRepository.cs b/MediaBrowser.Controller/Persistence/IMediaStreamRepository.cs index 665129eafd..de04ff021d 100644 --- a/MediaBrowser.Controller/Persistence/IMediaStreamRepository.cs +++ b/MediaBrowser.Controller/Persistence/IMediaStreamRepository.cs @@ -22,6 +22,13 @@ public interface IMediaStreamRepository IReadOnlyList<MediaStream> GetMediaStreams(MediaStreamQuery filter); /// <summary> + /// Gets all language codes of the provided stream type. + /// </summary> + /// <param name="mediaStreamType">The type of the media stream.</param> + /// <returns>IEnumerable{string}.</returns> + IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType); + + /// <summary> /// Saves the media streams. /// </summary> /// <param name="id">The identifier.</param> diff --git a/MediaBrowser.Controller/Providers/IProviderManager.cs b/MediaBrowser.Controller/Providers/IProviderManager.cs index 0d3a334dfb..c87f09a117 100644 --- a/MediaBrowser.Controller/Providers/IProviderManager.cs +++ b/MediaBrowser.Controller/Providers/IProviderManager.cs @@ -144,6 +144,17 @@ namespace MediaBrowser.Controller.Providers where T : BaseItem; /// <summary> + /// Gets the metadata providers for the provided item. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="libraryOptions">The library options.</param> + /// <param name="includeDisabled">Whether to include disabled providers.</param> + /// <typeparam name="T">The type of metadata provider.</typeparam> + /// <returns>The metadata providers.</returns> + IEnumerable<IMetadataProvider<T>> GetMetadataProviders<T>(BaseItem item, LibraryOptions libraryOptions, bool includeDisabled) + where T : BaseItem; + + /// <summary> /// Gets the metadata savers for the provided item. /// </summary> /// <param name="item">The item.</param> diff --git a/MediaBrowser.Model/Configuration/MetadataPluginType.cs b/MediaBrowser.Model/Configuration/MetadataPluginType.cs index 670d6e3837..476060ceef 100644 --- a/MediaBrowser.Model/Configuration/MetadataPluginType.cs +++ b/MediaBrowser.Model/Configuration/MetadataPluginType.cs @@ -15,6 +15,8 @@ namespace MediaBrowser.Model.Configuration MetadataSaver, SubtitleFetcher, LyricFetcher, - MediaSegmentProvider + MediaSegmentProvider, + LocalSimilarityProvider, + SimilarityProvider } } diff --git a/MediaBrowser.Model/Configuration/TypeOptions.cs b/MediaBrowser.Model/Configuration/TypeOptions.cs index d0179e5aab..3aa85034e5 100644 --- a/MediaBrowser.Model/Configuration/TypeOptions.cs +++ b/MediaBrowser.Model/Configuration/TypeOptions.cs @@ -304,11 +304,13 @@ namespace MediaBrowser.Model.Configuration public TypeOptions() { - MetadataFetchers = Array.Empty<string>(); - MetadataFetcherOrder = Array.Empty<string>(); - ImageFetchers = Array.Empty<string>(); - ImageFetcherOrder = Array.Empty<string>(); - ImageOptions = Array.Empty<ImageOption>(); + MetadataFetchers = []; + MetadataFetcherOrder = []; + ImageFetchers = []; + ImageFetcherOrder = []; + ImageOptions = []; + SimilarItemProviders = []; + SimilarItemProviderOrder = []; } public string Type { get; set; } @@ -323,6 +325,10 @@ namespace MediaBrowser.Model.Configuration public ImageOption[] ImageOptions { get; set; } + public string[] SimilarItemProviders { get; set; } + + public string[] SimilarItemProviderOrder { get; set; } + public ImageOption GetImageOptions(ImageType type) { foreach (var i in ImageOptions) diff --git a/MediaBrowser.Model/Querying/QueryFilters.cs b/MediaBrowser.Model/Querying/QueryFilters.cs index 73b27a7b06..095f460923 100644 --- a/MediaBrowser.Model/Querying/QueryFilters.cs +++ b/MediaBrowser.Model/Querying/QueryFilters.cs @@ -2,6 +2,7 @@ #pragma warning disable CS1591 using System; +using System.Collections.Generic; using MediaBrowser.Model.Dto; namespace MediaBrowser.Model.Querying @@ -12,10 +13,16 @@ namespace MediaBrowser.Model.Querying { Tags = Array.Empty<string>(); Genres = Array.Empty<NameGuidPair>(); + AudioLanguages = Array.Empty<NameValuePair>(); + SubtitleLanguages = Array.Empty<NameValuePair>(); } - public NameGuidPair[] Genres { get; set; } + public IReadOnlyList<NameGuidPair> Genres { get; set; } - public string[] Tags { get; set; } + public IReadOnlyList<string> Tags { get; set; } + + public IReadOnlyList<NameValuePair> AudioLanguages { get; set; } + + public IReadOnlyList<NameValuePair> SubtitleLanguages { get; set; } } } diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index 65edcb2a92..73df6d03d2 100644 --- a/MediaBrowser.Providers/Manager/ProviderManager.cs +++ b/MediaBrowser.Providers/Manager/ProviderManager.cs @@ -1,17 +1,20 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Mime; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using AsyncKeyedLock; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using Jellyfin.Extensions; +using Jellyfin.Extensions.Json; using MediaBrowser.Common.Net; using MediaBrowser.Controller; using MediaBrowser.Controller.BaseItemManager; @@ -64,6 +67,7 @@ namespace MediaBrowser.Providers.Manager private readonly PriorityQueue<(Guid ItemId, MetadataRefreshOptions RefreshOptions), RefreshPriority> _refreshQueue = new(); private readonly IMemoryCache _memoryCache; private readonly IMediaSegmentManager _mediaSegmentManager; + private readonly ISimilarItemsManager _similarItemsManager; private readonly AsyncKeyedLocker<string> _imageSaveLock = new(o => { o.PoolSize = 20; @@ -101,6 +105,7 @@ namespace MediaBrowser.Providers.Manager /// <param name="lyricManager">The lyric manager.</param> /// <param name="memoryCache">The memory cache.</param> /// <param name="mediaSegmentManager">The media segment manager.</param> + /// <param name="similarItemsManager">The similar items manager.</param> public ProviderManager( IHttpClientFactory httpClientFactory, ISubtitleManager subtitleManager, @@ -113,7 +118,8 @@ namespace MediaBrowser.Providers.Manager IBaseItemManager baseItemManager, ILyricManager lyricManager, IMemoryCache memoryCache, - IMediaSegmentManager mediaSegmentManager) + IMediaSegmentManager mediaSegmentManager, + ISimilarItemsManager similarItemsManager) { _logger = logger; _httpClientFactory = httpClientFactory; @@ -127,6 +133,7 @@ namespace MediaBrowser.Providers.Manager _lyricManager = lyricManager; _memoryCache = memoryCache; _mediaSegmentManager = mediaSegmentManager; + _similarItemsManager = similarItemsManager; CollectionFolder.LibraryOptionsUpdated += OnLibraryOptionsUpdated; } @@ -687,6 +694,14 @@ namespace MediaBrowser.Providers.Manager Type = MetadataPluginType.MediaSegmentProvider })); + // Similar items providers + var similarItemsProviders = _similarItemsManager.GetSimilarItemsProviders<T>(); + pluginList.AddRange(similarItemsProviders.Select(i => new MetadataPlugin + { + Name = i.Name, + Type = i.Type + })); + summary.Plugins = pluginList.ToArray(); var supportedImageTypes = imageProviders.OfType<IRemoteImageProvider>() diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj index ed0c63b97f..8b727a8cac 100644 --- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj +++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj @@ -52,6 +52,12 @@ <EmbeddedResource Include="Plugins\AudioDb\Configuration\config.html" /> <None Remove="Plugins\Omdb\Configuration\config.html" /> <EmbeddedResource Include="Plugins\Omdb\Configuration\config.html" /> + <None Remove="Plugins\ListenBrainz\Configuration\config.html" /> + <EmbeddedResource Include="Plugins\ListenBrainz\Configuration\config.html" /> + <None Remove="Plugins\ListenBrainz\Configuration\ListenBrainz_logo.svg" /> + <EmbeddedResource Include="Plugins\ListenBrainz\Configuration\ListenBrainz_logo.svg" /> + <None Remove="Plugins\ListenBrainz\Configuration\NOTICE.md" /> + <EmbeddedResource Include="Plugins\ListenBrainz\Configuration\NOTICE.md" /> <None Remove="Plugins\MusicBrainz\Configuration\config.html" /> <EmbeddedResource Include="Plugins\MusicBrainz\Configuration\config.html" /> <None Remove="Plugins\StudioImages\Configuration\config.html" /> diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/Api/ListenBrainzLabsClient.cs b/MediaBrowser.Providers/Plugins/ListenBrainz/Api/ListenBrainzLabsClient.cs new file mode 100644 index 0000000000..e080370b8c --- /dev/null +++ b/MediaBrowser.Providers/Plugins/ListenBrainz/Api/ListenBrainzLabsClient.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Net; +using MediaBrowser.Providers.Plugins.ListenBrainz.Api.Models; +using MediaBrowser.Providers.Plugins.ListenBrainz.Configuration; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.Plugins.ListenBrainz.Api; + +/// <summary> +/// Client for the ListenBrainz Labs API. +/// </summary> +public class ListenBrainzLabsClient : IDisposable +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger<ListenBrainzLabsClient> _logger; + private readonly SemaphoreSlim _rateLimitLock = new(1, 1); + + private DateTime _lastRequestTime = DateTime.MinValue; + + /// <summary> + /// Initializes a new instance of the <see cref="ListenBrainzLabsClient"/> class. + /// </summary> + /// <param name="httpClientFactory">The HTTP client factory.</param> + /// <param name="logger">The logger.</param> + public ListenBrainzLabsClient( + IHttpClientFactory httpClientFactory, + ILogger<ListenBrainzLabsClient> logger) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + /// <summary> + /// Gets similar artists for the given MusicBrainz artist ID. + /// </summary> + /// <param name="artistMbid">The MusicBrainz artist ID.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>A list of similar artist MusicBrainz IDs ordered by similarity score.</returns> + public async Task<IReadOnlyList<Guid>> GetSimilarArtistsAsync( + Guid artistMbid, + CancellationToken cancellationToken) + { + var config = ListenBrainzPlugin.Instance?.Configuration; + var baseUrl = config?.LabsServer ?? PluginConfiguration.DefaultLabsServer; + var algorithm = config?.AlgorithmString ?? new PluginConfiguration().AlgorithmString; + var rateLimit = config?.RateLimit ?? PluginConfiguration.DefaultRateLimit; + + // Enforce rate limit + await EnforceRateLimitAsync(rateLimit, cancellationToken).ConfigureAwait(false); + + var url = $"{baseUrl}/similar-artists/json?artist_mbids={artistMbid}&algorithm={algorithm}"; + + _logger.LogDebug("Fetching similar artists from ListenBrainz Labs: {Url}", url); + + try + { + var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); + var response = await httpClient.GetFromJsonAsync<List<SimilarArtistData>>(url, cancellationToken).ConfigureAwait(false); + + if (response is null || response.Count == 0) + { + _logger.LogDebug("No similar artists found for {ArtistMbid}", artistMbid); + return []; + } + + var similarMbids = response + .Where(a => !a.ArtistMbid.Equals(artistMbid)) // Exclude the source artist + .OrderByDescending(a => a.Score) + .Select(a => a.ArtistMbid) + .ToList(); + + _logger.LogDebug("Found {Count} similar artists for {ArtistMbid}", similarMbids.Count, artistMbid); + + return similarMbids; + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, "Failed to fetch similar artists from ListenBrainz Labs for {ArtistMbid}", artistMbid); + return []; + } + } + + /// <inheritdoc /> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <summary> + /// Releases unmanaged and - optionally - managed resources. + /// </summary> + /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _rateLimitLock.Dispose(); + } + } + + private async Task EnforceRateLimitAsync(double rateLimitSeconds, CancellationToken cancellationToken) + { + await _rateLimitLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var timeSinceLastRequest = DateTime.UtcNow - _lastRequestTime; + var requiredDelay = TimeSpan.FromSeconds(rateLimitSeconds) - timeSinceLastRequest; + + if (requiredDelay > TimeSpan.Zero) + { + await Task.Delay(requiredDelay, cancellationToken).ConfigureAwait(false); + } + + _lastRequestTime = DateTime.UtcNow; + } + finally + { + _rateLimitLock.Release(); + } + } +} diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/Api/Models/SimilarArtistData.cs b/MediaBrowser.Providers/Plugins/ListenBrainz/Api/Models/SimilarArtistData.cs new file mode 100644 index 0000000000..237f33ee3a --- /dev/null +++ b/MediaBrowser.Providers/Plugins/ListenBrainz/Api/Models/SimilarArtistData.cs @@ -0,0 +1,28 @@ +using System; +using System.Text.Json.Serialization; + +namespace MediaBrowser.Providers.Plugins.ListenBrainz.Api.Models; + +/// <summary> +/// A similar artist data entry from the ListenBrainz Labs API. +/// </summary> +public class SimilarArtistData +{ + /// <summary> + /// Gets or sets the MusicBrainz artist ID. + /// </summary> + [JsonPropertyName("artist_mbid")] + public Guid ArtistMbid { get; set; } + + /// <summary> + /// Gets or sets the artist name. + /// </summary> + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// <summary> + /// Gets or sets the similarity score. + /// </summary> + [JsonPropertyName("score")] + public double Score { get; set; } +} diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/Api/Models/SimilarArtistsResponse.cs b/MediaBrowser.Providers/Plugins/ListenBrainz/Api/Models/SimilarArtistsResponse.cs new file mode 100644 index 0000000000..12e8f25dcc --- /dev/null +++ b/MediaBrowser.Providers/Plugins/ListenBrainz/Api/Models/SimilarArtistsResponse.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace MediaBrowser.Providers.Plugins.ListenBrainz.Api.Models; + +/// <summary> +/// Response from ListenBrainz Labs similar-artists endpoint. +/// </summary> +public class SimilarArtistsResponse +{ + /// <summary> + /// Gets or sets the list of similar artists. + /// </summary> + [JsonPropertyName("data")] + public IReadOnlyList<SimilarArtistData>? Data { get; set; } +} diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/ListenBrainz_logo.svg b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/ListenBrainz_logo.svg new file mode 100644 index 0000000000..416a097f9c --- /dev/null +++ b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/ListenBrainz_logo.svg @@ -0,0 +1,60 @@ +<svg version="1.2" baseProfile="tiny-ps" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 450" width="800" height="450"> + <title>ListenBrainz_logo-svg</title> + <defs> + <image width="800" height="450" id="img1" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAyAAAAHCAQMAAAAtrT+LAAAAAXNSR0IB2cksfwAAAANQTFRF9tmZzxpnDgAAAENJREFUeJztwYEAAAAAw6D5U1/hAFUBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALwGsYoAAQbjkYoAAAAASUVORK5CYII="/> + <clipPath clipPathUnits="userSpaceOnUse" id="cp1"> + <path d="M173.35 178.24L224.79 178.24L224.79 269.03L173.35 269.03L173.35 178.24Z" /> + </clipPath> + <clipPath clipPathUnits="userSpaceOnUse" id="cp2"> + <path d="M173.35 178.24L224.79 178.24L224.79 269.03L173.35 269.03L173.35 178.24Z" /> + </clipPath> + <clipPath clipPathUnits="userSpaceOnUse" id="cp3"> + <path d="M173.35 178.24L224.79 178.24L224.79 269.03L173.35 269.03L173.35 178.24Z" /> + </clipPath> + </defs> + <style> + tspan { white-space:pre } + .shp0 { fill: #eb743b } + .shp1 { fill: #353070 } + .shp2 { fill: #000000 } + .shp3 { fill: #d3562c } + .shp4 { fill: #fffedb } + .shp5 { opacity: 0.251;fill: #000000 } + </style> + <use id="Background" href="#img1" x="0" y="0" /> + <path id="Layer" class="shp0" d="M173.35 152.82L173.35 296.82L234.35 261.82L234.35 187.82L173.35 152.82Z" /> + <path id="Layer" class="shp1" d="M168.35 152.82L107.35 187.82L107.35 261.82L168.35 296.82L168.35 152.82Z" /> + <g id="Layer" style="opacity: 0.071"> + <g id="Clip-Path" clip-path="url(#cp1)"> + <path id="Layer" class="shp2" d="M190.66 244.56C190.75 244.4 190.86 244.26 190.99 244.13C191.12 244 191.27 243.89 191.43 243.8C191.59 243.71 191.76 243.64 191.93 243.59C192.11 243.55 192.29 243.53 192.48 243.53C192.85 243.53 193.23 243.63 193.55 243.82C193.79 243.97 194 244.15 194.17 244.38C194.33 244.6 194.45 244.85 194.52 245.12C194.59 245.39 194.6 245.67 194.56 245.94C194.52 246.22 194.43 246.48 194.29 246.72C194.2 246.88 194.08 247.02 193.95 247.15C193.82 247.28 193.68 247.39 193.52 247.48C193.36 247.57 193.19 247.64 193.01 247.69C192.84 247.73 192.65 247.75 192.47 247.75C192.09 247.75 191.72 247.65 191.39 247.45C191.27 247.38 191.16 247.3 191.05 247.21C190.95 247.12 190.86 247.01 190.78 246.9C190.69 246.79 190.62 246.67 190.56 246.55C190.5 246.42 190.46 246.29 190.42 246.16C190.39 246.02 190.37 245.89 190.36 245.75C190.35 245.61 190.36 245.47 190.38 245.33C190.4 245.2 190.43 245.06 190.48 244.93C190.53 244.8 190.59 244.68 190.66 244.56M203.26 266.92C203.05 267.1 202.8 267.24 202.54 267.32C202.28 267.41 202 267.44 201.72 267.42C201.45 267.4 201.18 267.33 200.93 267.2C200.68 267.08 200.46 266.9 200.28 266.69C200.1 266.48 199.96 266.24 199.87 265.97C199.79 265.71 199.76 265.43 199.78 265.16C199.8 264.88 199.87 264.61 200 264.36C200.13 264.11 200.3 263.89 200.51 263.71C200.6 263.63 200.7 263.56 200.81 263.49C200.92 263.43 201.03 263.38 201.15 263.33C201.27 263.29 201.39 263.26 201.51 263.23C201.63 263.21 201.76 263.2 201.88 263.2C202.17 263.2 202.46 263.26 202.73 263.38C203 263.5 203.24 263.67 203.43 263.88C203.63 264.09 203.78 264.35 203.88 264.62C203.98 264.9 204.01 265.19 203.99 265.48C203.98 265.62 203.96 265.75 203.92 265.89C203.88 266.02 203.83 266.15 203.77 266.27C203.71 266.4 203.63 266.51 203.55 266.62C203.46 266.73 203.36 266.83 203.26 266.92M193.73 208.08C193.88 207.85 194.08 207.65 194.3 207.49C194.53 207.33 194.79 207.21 195.06 207.15C195.33 207.09 195.61 207.09 195.88 207.13C196.15 207.18 196.41 207.28 196.65 207.42C196.88 207.57 197.09 207.77 197.25 207.99C197.41 208.22 197.52 208.48 197.58 208.75C197.64 209.02 197.65 209.3 197.6 209.57C197.56 209.85 197.46 210.11 197.31 210.34C197.16 210.58 196.97 210.78 196.74 210.94C196.51 211.1 196.26 211.22 195.99 211.28C195.72 211.34 195.44 211.35 195.16 211.3C194.89 211.25 194.63 211.15 194.39 211C194.16 210.85 193.95 210.66 193.79 210.43C193.63 210.21 193.52 209.95 193.46 209.68C193.4 209.41 193.39 209.13 193.44 208.85C193.49 208.58 193.59 208.32 193.73 208.08ZM200.11 187.89C199.51 188.84 198.18 189.16 197.2 188.55L197.1 188.48C196.89 188.33 196.7 188.13 196.55 187.91C196.41 187.68 196.31 187.43 196.26 187.17C196.21 186.91 196.2 186.64 196.25 186.37C196.3 186.11 196.4 185.86 196.55 185.63C196.64 185.48 196.76 185.35 196.88 185.22C197.01 185.1 197.16 185 197.31 184.91C197.47 184.83 197.63 184.76 197.81 184.72C197.98 184.67 198.15 184.65 198.33 184.65C198.71 184.65 199.08 184.75 199.41 184.95C199.73 185.14 200 185.42 200.18 185.75C200.36 186.08 200.45 186.45 200.44 186.83C200.43 187.21 200.32 187.57 200.11 187.89M216.88 199.49C216.97 199.34 217.09 199.2 217.22 199.08C217.35 198.96 217.49 198.86 217.64 198.77C217.8 198.68 217.97 198.62 218.14 198.58C218.31 198.53 218.49 198.51 218.66 198.51C219.06 198.51 219.45 198.62 219.79 198.84C220.27 199.14 220.6 199.61 220.72 200.17C220.76 200.3 220.77 200.44 220.78 200.58C220.78 200.71 220.77 200.85 220.74 200.99C220.72 201.12 220.68 201.25 220.63 201.38C220.58 201.51 220.52 201.63 220.44 201.75C219.85 202.7 218.51 203.02 217.53 202.4L217.44 202.34C217.22 202.19 217.03 201.99 216.89 201.77C216.74 201.54 216.64 201.29 216.59 201.03C216.54 200.77 216.54 200.49 216.59 200.23C216.64 199.97 216.74 199.72 216.88 199.49M216.88 222.95C216.97 222.8 217.09 222.67 217.22 222.54C217.35 222.42 217.49 222.32 217.64 222.23C217.8 222.15 217.97 222.08 218.14 222.04C218.31 222 218.49 221.97 218.66 221.97C219.06 221.97 219.45 222.09 219.79 222.3C220.27 222.6 220.6 223.07 220.72 223.63C220.76 223.76 220.77 223.9 220.78 224.04C220.78 224.18 220.77 224.31 220.74 224.45C220.72 224.59 220.68 224.72 220.63 224.85C220.58 224.98 220.52 225.1 220.44 225.21C219.85 226.16 218.51 226.48 217.53 225.87L217.44 225.8C217.22 225.65 217.03 225.45 216.89 225.23C216.74 225 216.64 224.75 216.59 224.49C216.54 224.23 216.54 223.96 216.59 223.69C216.64 223.43 216.74 223.18 216.88 222.95M216.88 249.26C216.97 249.11 217.09 248.97 217.22 248.85C217.35 248.73 217.49 248.62 217.64 248.54C217.8 248.45 217.97 248.39 218.14 248.34C218.31 248.3 218.49 248.28 218.66 248.28C219.06 248.28 219.45 248.39 219.79 248.6C220.27 248.91 220.6 249.38 220.72 249.93C220.76 250.07 220.77 250.2 220.78 250.34C220.78 250.48 220.77 250.62 220.74 250.75C220.72 250.89 220.68 251.02 220.63 251.15C220.58 251.28 220.52 251.4 220.44 251.52C219.85 252.47 218.51 252.79 217.53 252.17L217.44 252.11C217.22 251.95 217.03 251.76 216.89 251.53C216.74 251.31 216.64 251.06 216.59 250.79C216.54 250.53 216.54 250.26 216.59 250C216.64 249.73 216.74 249.48 216.88 249.26" /> + </g> + <g id="Clip-Path" clip-path="url(#cp2)"> + <path id="Layer" fill-rule="evenodd" class="shp2" d="M206.53 258.93C206.79 259.23 207.02 259.56 207.22 259.91C207.42 260.26 207.58 260.63 207.71 261.01C207.83 261.39 207.92 261.78 207.96 262.18C208.01 262.58 208.02 262.98 207.99 263.38C207.96 263.78 207.89 264.17 207.78 264.56C207.67 264.95 207.53 265.32 207.35 265.68C207.17 266.04 206.95 266.38 206.7 266.69C206.45 267.01 206.17 267.3 205.87 267.56C205.59 267.79 205.3 268 204.99 268.19C204.68 268.37 204.35 268.52 204.01 268.65C203.67 268.78 203.32 268.87 202.96 268.94C202.61 269 202.25 269.03 201.89 269.03C201.49 269.03 201.1 268.99 200.71 268.92C200.32 268.84 199.94 268.73 199.57 268.58C199.21 268.43 198.85 268.24 198.52 268.02C198.19 267.81 197.88 267.56 197.6 267.28C195.75 267.98 192.69 269 190.58 269C190.48 269 190.37 269 190.27 269C188.3 268.89 187.03 268.03 186.01 267.34C184.44 266.28 182.95 265.27 177.6 266.63C175.24 267.71 173.63 267.79 173.36 267.82L173.36 263.82C173.86 263.72 174.73 263.55 175.91 263.01C175.94 263 175.97 262.98 176 262.97C176.03 262.95 176.06 262.94 176.09 262.92C176.12 262.91 176.15 262.89 176.18 262.88C176.21 262.87 176.24 262.86 176.27 262.85C179.62 261.24 184.79 257.38 188.84 248.16C188.56 247.95 188.29 247.72 188.05 247.47C187.8 247.22 187.58 246.94 187.39 246.65C187.19 246.36 187.02 246.05 186.88 245.73C186.74 245.41 186.62 245.07 186.54 244.73C186.44 244.34 186.38 243.95 186.36 243.55C186.33 243.15 186.35 242.74 186.41 242.35C186.47 241.95 186.57 241.56 186.7 241.18C186.84 240.8 187.01 240.44 187.22 240.1C187.49 239.64 187.82 239.23 188.19 238.86C188.57 238.49 188.99 238.17 189.45 237.91C189.91 237.65 190.4 237.45 190.91 237.32C191.42 237.18 191.95 237.11 192.48 237.11C192.75 237.11 193.02 237.13 193.3 237.17C193.57 237.2 193.84 237.26 194.1 237.33C194.37 237.4 194.62 237.5 194.88 237.6C195.13 237.71 195.37 237.83 195.61 237.97C198.51 239.71 199.46 243.47 197.73 246.37C197.47 246.8 197.16 247.21 196.79 247.56C196.43 247.92 196.03 248.23 195.59 248.49C195.15 248.75 194.68 248.95 194.2 249.1C193.71 249.24 193.2 249.32 192.69 249.34C190.12 255.34 187.05 259.41 184.11 262.18C185.84 262.51 187.09 263.23 188.26 264.02C189.18 264.65 189.66 264.95 190.48 264.99C191.55 265.03 193.86 264.36 195.81 263.66C195.75 263.16 195.75 262.66 195.81 262.16C195.87 261.66 196 261.17 196.18 260.7C196.36 260.23 196.6 259.79 196.89 259.38C197.18 258.97 197.52 258.59 197.9 258.26C198.51 257.74 199.22 257.34 199.98 257.09C200.74 256.84 201.55 256.74 202.35 256.8C203.15 256.87 203.94 257.08 204.65 257.45C205.37 257.81 206.01 258.32 206.53 258.93ZM190.66 242.15C190.59 242.27 190.53 242.4 190.48 242.53C190.43 242.66 190.4 242.79 190.38 242.93C190.36 243.07 190.35 243.2 190.36 243.34C190.37 243.48 190.39 243.62 190.42 243.75C190.46 243.89 190.5 244.02 190.56 244.14C190.62 244.27 190.69 244.39 190.78 244.5C190.86 244.61 190.95 244.71 191.05 244.8C191.16 244.89 191.27 244.98 191.39 245.05C191.72 245.24 192.09 245.35 192.47 245.35C192.65 245.35 192.84 245.33 193.01 245.28C193.19 245.23 193.36 245.16 193.52 245.07C193.68 244.98 193.82 244.87 193.95 244.75C194.08 244.62 194.2 244.47 194.29 244.32C194.58 243.83 194.66 243.26 194.52 242.71C194.38 242.17 194.04 241.7 193.56 241.42C193.07 241.13 192.5 241.05 191.95 241.18C191.41 241.32 190.94 241.67 190.66 242.15ZM203.26 264.51C203.36 264.42 203.46 264.32 203.55 264.22C203.63 264.11 203.71 263.99 203.77 263.87C203.83 263.74 203.88 263.62 203.92 263.48C203.96 263.35 203.98 263.21 203.99 263.07C204.01 262.78 203.98 262.49 203.88 262.22C203.78 261.94 203.63 261.69 203.43 261.47C203.24 261.26 203 261.09 202.73 260.97C202.46 260.86 202.17 260.8 201.88 260.8C201.76 260.8 201.63 260.81 201.51 260.83C201.39 260.85 201.27 260.88 201.15 260.93C201.03 260.97 200.92 261.02 200.81 261.09C200.7 261.15 200.6 261.23 200.51 261.31C200.3 261.49 200.13 261.71 200 261.96C199.87 262.2 199.8 262.47 199.78 262.75C199.76 263.03 199.79 263.31 199.87 263.57C199.96 263.83 200.1 264.08 200.28 264.29C200.46 264.5 200.68 264.67 200.93 264.8C201.18 264.92 201.45 265 201.72 265.02C202 265.04 202.28 265 202.54 264.92C202.8 264.83 203.05 264.69 203.26 264.51ZM204.3 183.03C204.39 183.42 204.44 183.82 204.45 184.22C204.46 184.62 204.43 185.02 204.36 185.41C204.3 185.8 204.19 186.19 204.04 186.56C203.9 186.94 203.72 187.29 203.5 187.63C203.23 188.07 202.9 188.47 202.52 188.82C202.15 189.17 201.73 189.48 201.28 189.72C200.83 189.97 200.35 190.16 199.85 190.29C199.36 190.42 198.84 190.48 198.33 190.48C198.18 190.48 198.03 190.47 197.88 190.46C197.73 190.45 197.58 190.43 197.43 190.41C197.29 190.39 197.14 190.36 196.99 190.33C196.85 190.29 196.7 190.26 196.56 190.21C194.18 193.04 191.3 194.53 188.78 195.31C190.8 196.86 192.59 198.72 194.12 200.88C195.7 200.52 197.41 200.76 198.79 201.63C201.65 203.44 202.5 207.23 200.7 210.08C200.42 210.52 200.09 210.92 199.72 211.27C199.34 211.63 198.93 211.93 198.47 212.18C198.02 212.43 197.54 212.62 197.04 212.74C196.54 212.87 196.03 212.94 195.52 212.93C194.36 212.93 193.23 212.61 192.25 211.98C189.4 210.18 188.55 206.39 190.34 203.54C190.37 203.49 190.4 203.45 190.43 203.4C190.46 203.36 190.49 203.31 190.53 203.27C190.56 203.22 190.59 203.18 190.62 203.13C190.66 203.09 190.69 203.04 190.72 203C188.45 199.89 185.56 197.52 182.12 195.96C182.11 195.96 182.11 195.96 182.1 195.96C178.91 194.5 175.8 193.96 173.35 193.82L173.35 189.82C176.13 189.98 179.67 190.54 183.36 192.14C184.59 192.16 189.66 191.97 193.3 187.85C192.96 187.36 192.69 186.82 192.51 186.25C192.32 185.68 192.22 185.09 192.21 184.49C192.2 183.89 192.27 183.3 192.43 182.72C192.59 182.14 192.84 181.59 193.16 181.09C193.43 180.65 193.76 180.25 194.13 179.9C194.51 179.55 194.92 179.24 195.38 178.99C195.83 178.74 196.31 178.55 196.81 178.43C197.3 178.3 197.82 178.24 198.33 178.24C198.62 178.24 198.91 178.26 199.19 178.3C199.47 178.34 199.76 178.4 200.03 178.48C200.31 178.56 200.58 178.66 200.84 178.78C201.1 178.89 201.35 179.03 201.6 179.18C201.94 179.4 202.26 179.64 202.55 179.92C202.84 180.2 203.1 180.5 203.34 180.83C203.57 181.16 203.77 181.51 203.93 181.88C204.09 182.25 204.22 182.63 204.3 183.03ZM193.73 205.67C193.59 205.91 193.49 206.17 193.44 206.45C193.39 206.72 193.4 207 193.46 207.27C193.52 207.54 193.63 207.8 193.79 208.03C193.95 208.25 194.16 208.45 194.39 208.6C194.63 208.75 194.89 208.85 195.16 208.89C195.44 208.94 195.72 208.93 195.99 208.87C196.26 208.81 196.51 208.7 196.74 208.54C196.97 208.38 197.16 208.17 197.31 207.94C197.46 207.7 197.56 207.44 197.6 207.17C197.65 206.89 197.64 206.61 197.58 206.34C197.52 206.07 197.41 205.81 197.25 205.59C197.09 205.36 196.88 205.17 196.65 205.02C196.41 204.87 196.15 204.77 195.88 204.73C195.61 204.68 195.33 204.69 195.06 204.75C194.79 204.81 194.53 204.92 194.3 205.08C194.08 205.24 193.88 205.44 193.73 205.67ZM200.11 185.49C200.32 185.17 200.43 184.8 200.44 184.42C200.45 184.05 200.36 183.67 200.18 183.34C200 183.01 199.73 182.73 199.41 182.54C199.08 182.35 198.71 182.25 198.33 182.25C198.15 182.25 197.98 182.27 197.81 182.31C197.63 182.35 197.47 182.42 197.31 182.51C197.16 182.59 197.01 182.7 196.88 182.82C196.76 182.94 196.64 183.08 196.55 183.23C196.4 183.45 196.3 183.7 196.25 183.97C196.2 184.23 196.21 184.5 196.26 184.76C196.31 185.03 196.41 185.28 196.55 185.5C196.7 185.73 196.89 185.92 197.11 186.08L197.2 186.14C198.18 186.76 199.51 186.44 200.11 185.49Z" /> + </g> + <g id="Clip-Path" clip-path="url(#cp3)"> + <path id="Layer" fill-rule="evenodd" class="shp2" d="M213 224.01C210.76 224.39 209.57 225.15 208.14 226.08C206.98 226.83 205.74 227.64 203.93 228.27C208.47 231.6 214.56 237.87 217.12 242.06C217.52 241.95 217.94 241.89 218.35 241.87C218.77 241.85 219.19 241.87 219.6 241.93C220.01 242 220.42 242.11 220.81 242.25C221.2 242.4 221.58 242.58 221.93 242.81C222.27 243.02 222.59 243.27 222.88 243.55C223.17 243.82 223.44 244.13 223.67 244.46C223.9 244.79 224.1 245.14 224.26 245.51C224.43 245.88 224.55 246.26 224.64 246.65C224.73 247.04 224.78 247.44 224.79 247.84C224.8 248.24 224.77 248.64 224.7 249.03C224.63 249.43 224.52 249.81 224.38 250.19C224.23 250.56 224.05 250.92 223.83 251.25C223.56 251.69 223.23 252.09 222.86 252.44C222.48 252.8 222.07 253.1 221.62 253.35C221.16 253.6 220.68 253.79 220.19 253.92C219.69 254.04 219.17 254.11 218.66 254.1C217.5 254.1 216.37 253.78 215.39 253.16C215.26 253.07 215.18 253.02 215.11 252.97C212.46 251.1 211.75 247.47 213.49 244.71C213.59 244.56 213.69 244.42 213.8 244.28C211.13 239.63 201.64 230.6 199.03 229.96C195.63 229.11 193.67 229.18 191.18 229.27C189.73 229.32 188.08 229.39 185.97 229.27C182.89 229.11 181.2 227.19 179.71 225.49C178.15 223.71 176.65 222.12 173.36 221.82L173.36 217.82C178.5 218.2 180.92 220.79 182.72 222.85C184.08 224.39 184.85 225.2 186.18 225.27C188.12 225.37 189.6 225.32 191.03 225.27C192.77 225.2 194.36 225.15 196.32 225.38C202 225.29 203.92 224.04 205.96 222.72C207.58 221.66 209.41 220.48 212.78 219.99C212.82 219.85 212.87 219.71 212.92 219.58C212.97 219.44 213.02 219.31 213.08 219.17C213.14 219.04 213.2 218.91 213.27 218.78C213.34 218.66 213.41 218.53 213.49 218.41C213.54 218.34 213.59 218.27 213.64 218.19C213.69 218.12 213.74 218.05 213.79 217.98C213.84 217.92 213.9 217.85 213.95 217.78C214.01 217.71 214.07 217.65 214.12 217.58C214.26 217.44 214.41 217.32 214.55 217.19C213.44 213.09 213.24 207.29 214.59 202.77C214.56 202.75 214.54 202.73 214.51 202.71C213.99 202.23 213.56 201.66 213.23 201.03C212.9 200.4 212.69 199.72 212.59 199.01C212.5 198.31 212.53 197.59 212.68 196.9C212.84 196.21 213.11 195.55 213.49 194.95C213.76 194.51 214.09 194.11 214.46 193.76C214.84 193.4 215.26 193.1 215.71 192.85C216.16 192.6 216.64 192.41 217.14 192.28C217.64 192.16 218.15 192.09 218.66 192.1C218.95 192.1 219.24 192.12 219.52 192.16C219.81 192.2 220.09 192.26 220.36 192.34C220.64 192.42 220.91 192.52 221.17 192.63C221.43 192.75 221.69 192.89 221.93 193.04C222.27 193.25 222.59 193.5 222.88 193.78C223.17 194.06 223.44 194.36 223.67 194.69C223.9 195.02 224.1 195.37 224.26 195.74C224.42 196.11 224.55 196.49 224.64 196.88C224.73 197.27 224.78 197.67 224.79 198.07C224.8 198.47 224.77 198.87 224.7 199.27C224.63 199.66 224.52 200.05 224.38 200.42C224.23 200.79 224.05 201.15 223.83 201.49C223.56 201.92 223.23 202.32 222.86 202.68C222.48 203.03 222.07 203.33 221.62 203.58C221.16 203.83 220.68 204.02 220.19 204.15C219.69 204.27 219.17 204.34 218.66 204.34C218.55 204.34 218.44 204.32 218.32 204.31C217.37 207.81 217.58 212.42 218.29 215.58C218.41 215.57 218.54 215.56 218.66 215.56C218.95 215.56 219.24 215.58 219.52 215.62C219.81 215.66 220.09 215.72 220.36 215.8C220.64 215.88 220.91 215.98 221.17 216.1C221.43 216.21 221.69 216.35 221.93 216.5C222.27 216.72 222.59 216.96 222.88 217.24C223.17 217.52 223.44 217.82 223.67 218.15C223.9 218.48 224.1 218.83 224.26 219.2C224.42 219.57 224.55 219.95 224.64 220.35C224.73 220.74 224.78 221.14 224.79 221.54C224.8 221.94 224.77 222.34 224.7 222.73C224.63 223.12 224.52 223.51 224.38 223.88C224.23 224.26 224.05 224.61 223.83 224.95C223.56 225.39 223.23 225.79 222.86 226.14C222.48 226.49 222.07 226.8 221.62 227.05C221.16 227.29 220.68 227.48 220.19 227.61C219.69 227.74 219.17 227.8 218.66 227.8C218.34 227.8 218.03 227.78 217.72 227.73C217.4 227.68 217.1 227.6 216.8 227.51C216.49 227.41 216.2 227.29 215.92 227.15C215.64 227.01 215.37 226.84 215.11 226.66C214.88 226.5 214.65 226.31 214.45 226.12C214.24 225.92 214.04 225.71 213.87 225.49C213.69 225.26 213.52 225.03 213.38 224.78C213.24 224.53 213.11 224.28 213 224.01ZM216.88 197.09C216.74 197.31 216.64 197.56 216.59 197.83C216.54 198.09 216.54 198.36 216.59 198.62C216.64 198.88 216.74 199.14 216.89 199.36C217.03 199.59 217.22 199.78 217.44 199.94L217.53 200C218.51 200.62 219.85 200.3 220.44 199.34C220.52 199.23 220.58 199.11 220.63 198.98C220.68 198.85 220.72 198.72 220.74 198.58C220.77 198.44 220.78 198.31 220.78 198.17C220.77 198.03 220.76 197.89 220.73 197.76C220.6 197.2 220.27 196.73 219.79 196.43C219.45 196.22 219.06 196.1 218.66 196.1C218.49 196.1 218.31 196.13 218.14 196.17C217.97 196.21 217.8 196.28 217.64 196.36C217.49 196.45 217.35 196.55 217.22 196.68C217.09 196.8 216.97 196.93 216.88 197.09ZM216.88 220.55C216.74 220.77 216.64 221.03 216.59 221.29C216.54 221.55 216.54 221.82 216.59 222.09C216.64 222.35 216.74 222.6 216.89 222.82C217.03 223.05 217.22 223.24 217.44 223.4L217.53 223.46C218.51 224.08 219.85 223.76 220.44 222.81C220.52 222.69 220.58 222.57 220.63 222.44C220.68 222.31 220.72 222.18 220.74 222.04C220.77 221.91 220.78 221.77 220.78 221.63C220.77 221.49 220.76 221.36 220.72 221.22C220.6 220.67 220.27 220.2 219.79 219.89C219.45 219.68 219.06 219.57 218.66 219.57C218.49 219.57 218.31 219.59 218.14 219.63C217.97 219.68 217.8 219.74 217.64 219.83C217.49 219.91 217.35 220.02 217.22 220.14C217.09 220.26 216.97 220.4 216.88 220.55ZM216.88 246.85C216.74 247.08 216.64 247.33 216.59 247.59C216.54 247.86 216.54 248.13 216.59 248.39C216.64 248.65 216.74 248.9 216.89 249.13C217.03 249.35 217.22 249.55 217.44 249.7L217.53 249.76C218.51 250.38 219.85 250.06 220.44 249.11C220.52 249 220.58 248.87 220.63 248.74C220.68 248.62 220.72 248.48 220.74 248.35C220.77 248.21 220.78 248.07 220.78 247.94C220.77 247.8 220.76 247.66 220.72 247.53C220.6 246.97 220.27 246.5 219.79 246.2C219.45 245.98 219.06 245.87 218.66 245.87C218.49 245.87 218.31 245.89 218.14 245.94C217.97 245.98 217.8 246.05 217.64 246.13C217.49 246.22 217.35 246.32 217.22 246.44C217.09 246.56 216.97 246.7 216.88 246.85Z" /> + </g> + </g> + <path id="Layer" fill-rule="evenodd" class="shp3" d="M206.53 261.93C206.79 262.23 207.02 262.56 207.22 262.91C207.42 263.26 207.58 263.63 207.71 264.01C207.83 264.39 207.92 264.78 207.96 265.18C208.01 265.58 208.02 265.98 207.99 266.38C207.96 266.78 207.89 267.17 207.78 267.56C207.67 267.95 207.53 268.32 207.35 268.68C207.17 269.04 206.95 269.38 206.7 269.69C206.45 270.01 206.17 270.3 205.87 270.56C205.59 270.79 205.3 271 204.99 271.19C204.68 271.37 204.35 271.52 204.01 271.65C203.67 271.78 203.32 271.87 202.96 271.94C202.61 272 202.25 272.03 201.89 272.03C201.49 272.03 201.1 271.99 200.71 271.92C200.32 271.84 199.94 271.73 199.57 271.58C199.21 271.43 198.85 271.24 198.52 271.02C198.19 270.81 197.88 270.56 197.6 270.28C195.75 270.98 192.69 272 190.58 272C190.48 272 190.37 272 190.27 272C188.3 271.89 187.03 271.03 186.01 270.34C184.44 269.28 182.95 268.27 177.6 269.63C175.24 270.71 173.63 270.79 173.36 270.82L173.36 266.82C173.86 266.72 174.73 266.55 175.91 266.01C175.94 266 175.97 265.98 176 265.97C176.03 265.95 176.06 265.94 176.09 265.92C176.12 265.91 176.15 265.89 176.18 265.88C176.21 265.87 176.24 265.86 176.27 265.85C179.62 264.24 184.79 260.38 188.84 251.16C188.56 250.95 188.29 250.72 188.05 250.47C187.8 250.22 187.58 249.94 187.39 249.65C187.19 249.36 187.02 249.05 186.88 248.73C186.74 248.41 186.62 248.07 186.54 247.73C186.44 247.34 186.38 246.95 186.36 246.55C186.33 246.15 186.35 245.74 186.41 245.35C186.47 244.95 186.57 244.56 186.7 244.18C186.84 243.8 187.01 243.44 187.22 243.1C187.49 242.64 187.82 242.23 188.19 241.86C188.57 241.49 188.99 241.17 189.45 240.91C189.91 240.65 190.4 240.45 190.91 240.32C191.42 240.18 191.95 240.11 192.48 240.11C192.75 240.11 193.02 240.13 193.3 240.17C193.57 240.2 193.84 240.26 194.1 240.33C194.37 240.4 194.62 240.5 194.88 240.6C195.13 240.71 195.37 240.83 195.61 240.97C198.51 242.71 199.46 246.47 197.73 249.37C197.47 249.8 197.16 250.21 196.79 250.56C196.43 250.92 196.03 251.23 195.59 251.49C195.15 251.75 194.68 251.95 194.2 252.1C193.71 252.24 193.2 252.32 192.69 252.34C190.12 258.35 187.05 262.41 184.11 265.18C185.84 265.51 187.09 266.23 188.26 267.02C189.18 267.65 189.66 267.95 190.48 267.99C191.55 268.03 193.86 267.36 195.81 266.66C195.75 266.16 195.75 265.66 195.81 265.16C195.87 264.66 196 264.17 196.18 263.7C196.36 263.23 196.6 262.79 196.89 262.38C197.18 261.97 197.52 261.59 197.9 261.26C198.51 260.74 199.22 260.34 199.98 260.09C200.74 259.84 201.55 259.74 202.35 259.8C203.15 259.87 203.94 260.08 204.65 260.45C205.37 260.81 206.01 261.32 206.53 261.93ZM190.66 245.15C190.59 245.27 190.53 245.4 190.48 245.53C190.43 245.66 190.4 245.79 190.38 245.93C190.36 246.07 190.35 246.2 190.36 246.34C190.37 246.48 190.39 246.62 190.42 246.75C190.46 246.89 190.5 247.02 190.56 247.14C190.62 247.27 190.69 247.39 190.78 247.5C190.86 247.61 190.95 247.71 191.05 247.8C191.16 247.89 191.27 247.98 191.39 248.05C191.72 248.24 192.09 248.35 192.47 248.35C192.65 248.35 192.84 248.33 193.01 248.28C193.19 248.23 193.36 248.16 193.52 248.07C193.68 247.98 193.82 247.87 193.95 247.75C194.08 247.62 194.2 247.47 194.29 247.32C194.58 246.83 194.66 246.26 194.52 245.71C194.38 245.17 194.04 244.7 193.56 244.42C193.07 244.13 192.5 244.05 191.95 244.18C191.41 244.32 190.94 244.67 190.66 245.15ZM203.26 267.51C203.36 267.42 203.46 267.32 203.55 267.22C203.63 267.11 203.71 266.99 203.77 266.87C203.83 266.74 203.88 266.62 203.92 266.48C203.96 266.35 203.98 266.21 203.99 266.07C204.01 265.78 203.98 265.49 203.88 265.22C203.78 264.94 203.63 264.69 203.43 264.47C203.24 264.26 203 264.09 202.73 263.97C202.46 263.86 202.17 263.8 201.88 263.8C201.76 263.8 201.63 263.81 201.51 263.83C201.39 263.85 201.27 263.88 201.15 263.93C201.03 263.97 200.92 264.02 200.81 264.09C200.7 264.15 200.6 264.23 200.51 264.31C200.3 264.49 200.13 264.71 200 264.96C199.87 265.2 199.8 265.47 199.78 265.75C199.76 266.03 199.79 266.31 199.87 266.57C199.96 266.83 200.1 267.08 200.28 267.29C200.46 267.5 200.68 267.67 200.93 267.8C201.18 267.92 201.45 268 201.72 268.02C202 268.04 202.28 268 202.54 267.92C202.8 267.83 203.05 267.69 203.26 267.51Z" /> + <path id="Layer" class="shp2" d="" /> + <path id="Layer" fill-rule="evenodd" class="shp3" d="M204.3 185.03C204.39 185.42 204.44 185.82 204.45 186.22C204.46 186.62 204.43 187.02 204.36 187.41C204.3 187.81 204.19 188.19 204.04 188.57C203.9 188.94 203.72 189.3 203.5 189.63C203.23 190.07 202.9 190.47 202.53 190.82C202.15 191.18 201.73 191.48 201.28 191.73C200.83 191.98 200.35 192.17 199.85 192.29C199.36 192.42 198.84 192.48 198.33 192.48C198.18 192.48 198.03 192.48 197.88 192.46C197.73 192.45 197.58 192.44 197.43 192.41C197.29 192.39 197.14 192.36 196.99 192.33C196.85 192.3 196.7 192.26 196.56 192.22C194.18 195.04 191.3 196.53 188.78 197.31C190.8 198.86 192.59 200.72 194.12 202.88C195.7 202.52 197.41 202.76 198.79 203.63C201.65 205.44 202.5 209.24 200.7 212.09C200.42 212.52 200.09 212.92 199.72 213.28C199.34 213.63 198.93 213.93 198.47 214.18C198.02 214.43 197.54 214.62 197.04 214.75C196.54 214.88 196.03 214.94 195.52 214.94C194.36 214.94 193.23 214.61 192.25 213.99C189.4 212.19 188.55 208.4 190.34 205.54C190.37 205.5 190.4 205.45 190.43 205.4C190.46 205.36 190.49 205.31 190.53 205.27C190.56 205.22 190.59 205.18 190.62 205.14C190.66 205.09 190.69 205.05 190.72 205C188.45 201.89 185.56 199.53 182.12 197.96C182.11 197.96 182.11 197.96 182.1 197.96C178.91 196.51 175.8 195.97 173.35 195.82L173.35 191.83C176.13 191.98 179.67 192.54 183.36 194.14C184.59 194.16 189.66 193.97 193.3 189.85C192.96 189.36 192.69 188.82 192.51 188.25C192.32 187.68 192.22 187.09 192.21 186.49C192.2 185.9 192.27 185.3 192.43 184.72C192.59 184.15 192.84 183.6 193.16 183.09C193.43 182.66 193.76 182.26 194.13 181.9C194.51 181.55 194.92 181.25 195.38 181C195.83 180.75 196.31 180.56 196.81 180.43C197.3 180.3 197.82 180.24 198.33 180.24C198.62 180.24 198.91 180.26 199.19 180.3C199.47 180.34 199.76 180.4 200.03 180.48C200.31 180.56 200.58 180.66 200.84 180.78C201.1 180.9 201.35 181.03 201.6 181.19C201.94 181.4 202.26 181.65 202.55 181.93C202.84 182.2 203.1 182.51 203.34 182.84C203.57 183.17 203.77 183.52 203.93 183.89C204.09 184.25 204.22 184.64 204.3 185.03ZM193.73 207.68C193.59 207.91 193.49 208.18 193.44 208.45C193.39 208.72 193.4 209 193.46 209.28C193.52 209.55 193.63 209.8 193.79 210.03C193.95 210.26 194.16 210.45 194.39 210.6C194.63 210.75 194.89 210.85 195.16 210.9C195.44 210.95 195.72 210.94 195.99 210.88C196.26 210.82 196.51 210.7 196.74 210.54C196.97 210.38 197.16 210.18 197.31 209.94C197.46 209.71 197.56 209.44 197.6 209.17C197.65 208.9 197.64 208.62 197.58 208.35C197.52 208.07 197.41 207.82 197.25 207.59C197.09 207.36 196.88 207.17 196.65 207.02C196.41 206.88 196.15 206.78 195.88 206.73C195.61 206.68 195.33 206.69 195.06 206.75C194.79 206.81 194.53 206.93 194.3 207.08C194.08 207.24 193.88 207.44 193.73 207.68ZM200.11 187.49C200.32 187.17 200.43 186.8 200.44 186.43C200.45 186.05 200.36 185.68 200.18 185.34C200 185.01 199.73 184.74 199.41 184.55C199.08 184.35 198.71 184.25 198.33 184.25C198.15 184.25 197.98 184.27 197.81 184.32C197.63 184.36 197.47 184.42 197.31 184.51C197.16 184.59 197.01 184.7 196.88 184.82C196.76 184.94 196.64 185.08 196.55 185.23C196.4 185.46 196.3 185.71 196.25 185.97C196.2 186.23 196.21 186.5 196.26 186.77C196.31 187.03 196.41 187.28 196.55 187.51C196.7 187.73 196.89 187.93 197.11 188.08L197.2 188.14C198.18 188.76 199.51 188.44 200.11 187.49Z" /> + <path id="Layer" fill-rule="evenodd" class="shp3" d="M213 227.01C210.76 227.39 209.57 228.15 208.14 229.08C206.98 229.83 205.74 230.64 203.93 231.26C208.47 234.6 214.56 240.87 217.12 245.06C217.52 244.95 217.94 244.89 218.35 244.87C218.77 244.85 219.19 244.87 219.6 244.93C220.01 245 220.42 245.1 220.81 245.25C221.2 245.4 221.58 245.58 221.93 245.81C222.27 246.02 222.59 246.27 222.88 246.55C223.17 246.82 223.44 247.13 223.67 247.46C223.9 247.79 224.1 248.14 224.26 248.51C224.43 248.87 224.55 249.26 224.64 249.65C224.73 250.04 224.78 250.44 224.79 250.84C224.8 251.24 224.77 251.64 224.7 252.03C224.63 252.43 224.52 252.81 224.38 253.19C224.23 253.56 224.05 253.92 223.83 254.25C223.56 254.69 223.23 255.09 222.86 255.44C222.48 255.8 222.07 256.1 221.62 256.35C221.16 256.6 220.68 256.79 220.19 256.91C219.69 257.04 219.17 257.1 218.66 257.1C217.5 257.1 216.37 256.78 215.39 256.15C215.26 256.07 215.18 256.02 215.11 255.96C212.46 254.1 211.75 250.47 213.49 247.71C213.59 247.56 213.69 247.42 213.8 247.28C211.13 242.63 201.64 233.6 199.03 232.96C195.63 232.11 193.67 232.18 191.18 232.27C189.73 232.32 188.08 232.39 185.97 232.27C182.89 232.1 181.2 230.18 179.71 228.49C178.15 226.71 176.65 225.12 173.36 224.82L173.36 220.82C178.5 221.2 180.92 223.79 182.72 225.84C184.08 227.39 184.85 228.2 186.18 228.27C188.12 228.37 189.6 228.32 191.03 228.27C192.77 228.2 194.36 228.15 196.32 228.38C202 228.29 203.92 227.04 205.96 225.72C207.58 224.66 209.41 223.48 212.78 222.99C212.82 222.85 212.87 222.71 212.92 222.58C212.97 222.44 213.02 222.31 213.08 222.17C213.14 222.04 213.2 221.91 213.27 221.78C213.34 221.66 213.41 221.53 213.49 221.41C213.54 221.34 213.59 221.27 213.64 221.19C213.69 221.12 213.74 221.05 213.79 220.98C213.84 220.92 213.9 220.85 213.95 220.78C214.01 220.71 214.07 220.65 214.12 220.58C214.26 220.44 214.41 220.32 214.55 220.19C213.44 216.09 213.24 210.29 214.59 205.77C214.56 205.75 214.54 205.73 214.51 205.71C213.99 205.23 213.56 204.66 213.23 204.03C212.9 203.4 212.69 202.72 212.59 202.01C212.5 201.31 212.53 200.59 212.68 199.9C212.84 199.21 213.11 198.55 213.49 197.95C213.76 197.51 214.09 197.11 214.46 196.76C214.84 196.4 215.26 196.1 215.71 195.85C216.16 195.6 216.64 195.41 217.14 195.28C217.64 195.16 218.15 195.09 218.66 195.1C218.95 195.1 219.24 195.12 219.52 195.16C219.81 195.2 220.09 195.26 220.36 195.34C220.64 195.42 220.91 195.52 221.17 195.63C221.43 195.75 221.69 195.89 221.93 196.04C222.27 196.25 222.59 196.5 222.88 196.78C223.17 197.06 223.44 197.36 223.67 197.69C223.9 198.02 224.1 198.37 224.26 198.74C224.42 199.11 224.55 199.49 224.64 199.88C224.73 200.27 224.78 200.67 224.79 201.07C224.8 201.47 224.77 201.87 224.7 202.27C224.63 202.66 224.52 203.05 224.38 203.42C224.23 203.79 224.05 204.15 223.83 204.49C223.56 204.92 223.23 205.32 222.86 205.68C222.48 206.03 222.07 206.33 221.62 206.58C221.16 206.83 220.68 207.02 220.19 207.15C219.69 207.27 219.17 207.34 218.66 207.34C218.55 207.34 218.44 207.32 218.32 207.31C217.37 210.81 217.58 215.42 218.29 218.58C218.41 218.57 218.54 218.56 218.66 218.56C218.95 218.56 219.24 218.58 219.52 218.62C219.81 218.66 220.09 218.72 220.36 218.8C220.64 218.88 220.91 218.98 221.17 219.1C221.43 219.21 221.69 219.35 221.93 219.5C222.27 219.72 222.59 219.96 222.88 220.24C223.17 220.52 223.44 220.82 223.67 221.15C223.9 221.48 224.1 221.83 224.26 222.2C224.42 222.57 224.55 222.95 224.64 223.35C224.73 223.74 224.78 224.14 224.79 224.54C224.8 224.94 224.77 225.34 224.7 225.73C224.63 226.12 224.52 226.51 224.38 226.88C224.23 227.26 224.05 227.61 223.83 227.95C223.56 228.39 223.23 228.79 222.86 229.14C222.48 229.49 222.07 229.8 221.62 230.05C221.16 230.29 220.68 230.48 220.19 230.61C219.69 230.74 219.17 230.8 218.66 230.8C218.34 230.8 218.03 230.78 217.72 230.73C217.4 230.68 217.1 230.6 216.8 230.51C216.49 230.41 216.2 230.29 215.92 230.15C215.64 230.01 215.37 229.84 215.11 229.66C214.88 229.5 214.65 229.31 214.45 229.12C214.24 228.92 214.04 228.71 213.87 228.49C213.69 228.26 213.52 228.03 213.38 227.78C213.24 227.53 213.11 227.28 213 227.01ZM216.88 200.09C216.74 200.31 216.64 200.56 216.59 200.83C216.54 201.09 216.54 201.36 216.59 201.62C216.64 201.88 216.74 202.14 216.89 202.36C217.03 202.59 217.22 202.78 217.44 202.94L217.53 203C218.51 203.62 219.85 203.3 220.44 202.34C220.52 202.23 220.58 202.11 220.63 201.98C220.68 201.85 220.72 201.72 220.74 201.58C220.77 201.44 220.78 201.31 220.78 201.17C220.77 201.03 220.76 200.89 220.73 200.76C220.6 200.2 220.27 199.73 219.79 199.43C219.45 199.22 219.06 199.1 218.66 199.1C218.49 199.1 218.31 199.13 218.14 199.17C217.97 199.21 217.8 199.28 217.64 199.36C217.49 199.45 217.35 199.55 217.22 199.68C217.09 199.8 216.97 199.93 216.88 200.09ZM216.88 223.55C216.74 223.77 216.64 224.03 216.59 224.29C216.54 224.55 216.54 224.82 216.59 225.09C216.64 225.35 216.74 225.6 216.89 225.82C217.03 226.05 217.22 226.24 217.44 226.4L217.53 226.46C218.51 227.08 219.85 226.76 220.44 225.81C220.52 225.69 220.58 225.57 220.63 225.44C220.68 225.31 220.72 225.18 220.74 225.04C220.77 224.91 220.78 224.77 220.78 224.63C220.77 224.49 220.76 224.36 220.72 224.22C220.6 223.67 220.27 223.2 219.79 222.89C219.45 222.68 219.06 222.57 218.66 222.57C218.49 222.57 218.31 222.59 218.14 222.63C217.97 222.68 217.8 222.74 217.64 222.83C217.49 222.91 217.35 223.02 217.22 223.14C217.09 223.26 216.97 223.4 216.88 223.55ZM216.88 249.85C216.74 250.08 216.64 250.33 216.59 250.59C216.54 250.86 216.54 251.13 216.59 251.39C216.64 251.65 216.74 251.9 216.89 252.13C217.03 252.35 217.22 252.55 217.44 252.7L217.53 252.76C218.51 253.38 219.85 253.06 220.44 252.11C220.52 252 220.58 251.87 220.63 251.74C220.68 251.62 220.72 251.48 220.74 251.35C220.77 251.21 220.78 251.07 220.78 250.94C220.77 250.8 220.76 250.66 220.72 250.53C220.6 249.97 220.27 249.5 219.79 249.2C219.45 248.98 219.06 248.87 218.66 248.87C218.49 248.87 218.31 248.89 218.14 248.94C217.97 248.98 217.8 249.05 217.64 249.13C217.49 249.22 217.35 249.32 217.22 249.44C217.09 249.56 216.97 249.7 216.88 249.85Z" /> + <path id="Layer" fill-rule="evenodd" class="shp4" d="M206.53 258.93C206.79 259.23 207.02 259.56 207.22 259.91C207.42 260.26 207.58 260.63 207.71 261.01C207.83 261.39 207.92 261.78 207.96 262.18C208.01 262.58 208.02 262.98 207.99 263.38C207.96 263.78 207.89 264.17 207.78 264.56C207.67 264.95 207.53 265.32 207.35 265.68C207.17 266.04 206.95 266.38 206.7 266.69C206.45 267.01 206.17 267.3 205.87 267.56C205.59 267.79 205.3 268 204.99 268.19C204.68 268.37 204.35 268.52 204.01 268.65C203.67 268.78 203.32 268.87 202.96 268.94C202.61 269 202.25 269.03 201.89 269.03C201.49 269.03 201.1 268.99 200.71 268.92C200.32 268.84 199.94 268.73 199.57 268.58C199.21 268.43 198.85 268.24 198.52 268.02C198.19 267.81 197.88 267.56 197.6 267.28C195.75 267.98 192.69 269 190.58 269C190.48 269 190.37 269 190.27 269C188.3 268.89 187.03 268.03 186.01 267.34C184.44 266.28 182.95 265.27 177.6 266.63C175.24 267.71 173.63 267.79 173.36 267.82L173.36 263.82C173.86 263.72 174.73 263.55 175.91 263.01C175.94 263 175.97 262.98 176 262.97C176.03 262.95 176.06 262.94 176.09 262.92C176.12 262.91 176.15 262.89 176.18 262.88C176.21 262.87 176.24 262.86 176.27 262.85C179.62 261.24 184.79 257.38 188.84 248.16C188.56 247.95 188.29 247.72 188.05 247.47C187.8 247.22 187.58 246.94 187.39 246.65C187.19 246.36 187.02 246.05 186.88 245.73C186.74 245.41 186.62 245.07 186.54 244.73C186.44 244.34 186.38 243.95 186.36 243.55C186.33 243.15 186.35 242.74 186.41 242.35C186.47 241.95 186.57 241.56 186.7 241.18C186.84 240.8 187.01 240.44 187.22 240.1C187.49 239.64 187.82 239.23 188.19 238.86C188.57 238.49 188.99 238.17 189.45 237.91C189.91 237.65 190.4 237.45 190.91 237.32C191.42 237.18 191.95 237.11 192.48 237.11C192.75 237.11 193.02 237.13 193.3 237.17C193.57 237.2 193.84 237.26 194.1 237.33C194.37 237.4 194.62 237.5 194.88 237.6C195.13 237.71 195.37 237.83 195.61 237.97C198.51 239.71 199.46 243.47 197.73 246.37C197.47 246.8 197.16 247.21 196.79 247.56C196.43 247.92 196.03 248.23 195.59 248.49C195.15 248.75 194.68 248.95 194.2 249.1C193.71 249.24 193.2 249.32 192.69 249.34C190.12 255.34 187.05 259.41 184.11 262.18C185.84 262.51 187.09 263.23 188.26 264.02C189.18 264.65 189.66 264.95 190.48 264.99C191.55 265.03 193.86 264.36 195.81 263.66C195.75 263.16 195.75 262.66 195.81 262.16C195.87 261.66 196 261.17 196.18 260.7C196.36 260.23 196.6 259.79 196.89 259.38C197.18 258.97 197.52 258.59 197.9 258.26C198.51 257.74 199.22 257.34 199.98 257.09C200.74 256.84 201.55 256.74 202.35 256.8C203.15 256.87 203.94 257.08 204.65 257.45C205.37 257.81 206.01 258.32 206.53 258.93ZM190.66 242.15C190.59 242.27 190.53 242.4 190.48 242.53C190.43 242.66 190.4 242.79 190.38 242.93C190.36 243.07 190.35 243.2 190.36 243.34C190.37 243.48 190.39 243.62 190.42 243.75C190.46 243.89 190.5 244.02 190.56 244.14C190.62 244.27 190.69 244.39 190.78 244.5C190.86 244.61 190.95 244.71 191.05 244.8C191.16 244.89 191.27 244.98 191.39 245.05C191.72 245.24 192.09 245.35 192.47 245.35C192.65 245.35 192.84 245.33 193.01 245.28C193.19 245.23 193.36 245.16 193.52 245.07C193.68 244.98 193.82 244.87 193.95 244.75C194.08 244.62 194.2 244.47 194.29 244.32C194.58 243.83 194.66 243.26 194.52 242.71C194.38 242.17 194.04 241.7 193.56 241.42C193.07 241.13 192.5 241.05 191.95 241.18C191.41 241.32 190.94 241.67 190.66 242.15ZM203.26 264.51C203.36 264.42 203.46 264.32 203.55 264.22C203.63 264.11 203.71 263.99 203.77 263.87C203.83 263.74 203.88 263.62 203.92 263.48C203.96 263.35 203.98 263.21 203.99 263.07C204.01 262.78 203.98 262.49 203.88 262.22C203.78 261.94 203.63 261.69 203.43 261.47C203.24 261.26 203 261.09 202.73 260.97C202.46 260.86 202.17 260.8 201.88 260.8C201.76 260.8 201.63 260.81 201.51 260.83C201.39 260.85 201.27 260.88 201.15 260.93C201.03 260.97 200.92 261.02 200.81 261.09C200.7 261.15 200.6 261.23 200.51 261.31C200.3 261.49 200.13 261.71 200 261.96C199.87 262.2 199.8 262.47 199.78 262.75C199.76 263.03 199.79 263.31 199.87 263.57C199.96 263.83 200.1 264.08 200.28 264.29C200.46 264.5 200.68 264.67 200.93 264.8C201.18 264.92 201.45 265 201.72 265.02C202 265.04 202.28 265 202.54 264.92C202.8 264.83 203.05 264.69 203.26 264.51ZM204.3 183.03C204.39 183.42 204.44 183.82 204.45 184.22C204.46 184.62 204.43 185.02 204.36 185.41C204.3 185.8 204.19 186.19 204.04 186.56C203.9 186.94 203.72 187.29 203.5 187.63C203.23 188.07 202.9 188.47 202.52 188.82C202.15 189.17 201.73 189.48 201.28 189.72C200.83 189.97 200.35 190.16 199.85 190.29C199.36 190.42 198.84 190.48 198.33 190.48C198.18 190.48 198.03 190.47 197.88 190.46C197.73 190.45 197.58 190.43 197.43 190.41C197.29 190.39 197.14 190.36 196.99 190.33C196.85 190.29 196.7 190.26 196.56 190.21C194.18 193.04 191.3 194.53 188.78 195.31C190.8 196.86 192.59 198.72 194.12 200.88C195.7 200.52 197.41 200.76 198.79 201.63C201.65 203.44 202.5 207.23 200.7 210.08C200.42 210.52 200.09 210.92 199.72 211.27C199.34 211.63 198.93 211.93 198.47 212.18C198.02 212.43 197.54 212.62 197.04 212.74C196.54 212.87 196.03 212.94 195.52 212.93C194.36 212.93 193.23 212.61 192.25 211.98C189.4 210.18 188.55 206.39 190.34 203.54C190.37 203.49 190.4 203.45 190.43 203.4C190.46 203.36 190.49 203.31 190.53 203.27C190.56 203.22 190.59 203.18 190.62 203.13C190.66 203.09 190.69 203.04 190.72 203C188.45 199.89 185.56 197.52 182.12 195.96C182.11 195.96 182.11 195.96 182.1 195.96C178.91 194.5 175.8 193.96 173.35 193.82L173.35 189.82C176.13 189.98 179.67 190.54 183.36 192.14C184.59 192.16 189.66 191.97 193.3 187.85C192.96 187.36 192.69 186.82 192.51 186.25C192.32 185.68 192.22 185.09 192.21 184.49C192.2 183.89 192.27 183.3 192.43 182.72C192.59 182.14 192.84 181.59 193.16 181.09C193.43 180.65 193.76 180.25 194.13 179.9C194.51 179.55 194.92 179.24 195.38 178.99C195.83 178.74 196.31 178.55 196.81 178.43C197.3 178.3 197.82 178.24 198.33 178.24C198.62 178.24 198.91 178.26 199.19 178.3C199.47 178.34 199.76 178.4 200.03 178.48C200.31 178.56 200.58 178.66 200.84 178.78C201.1 178.89 201.35 179.03 201.6 179.18C201.94 179.4 202.26 179.64 202.55 179.92C202.84 180.2 203.1 180.5 203.34 180.83C203.57 181.16 203.77 181.51 203.93 181.88C204.09 182.25 204.22 182.63 204.3 183.03ZM193.73 205.67C193.59 205.91 193.49 206.17 193.44 206.45C193.39 206.72 193.4 207 193.46 207.27C193.52 207.54 193.63 207.8 193.79 208.03C193.95 208.25 194.16 208.45 194.39 208.6C194.63 208.75 194.89 208.85 195.16 208.89C195.44 208.94 195.72 208.93 195.99 208.87C196.26 208.81 196.51 208.7 196.74 208.54C196.97 208.38 197.16 208.17 197.31 207.94C197.46 207.7 197.56 207.44 197.6 207.17C197.65 206.89 197.64 206.61 197.58 206.34C197.52 206.07 197.41 205.81 197.25 205.59C197.09 205.36 196.88 205.17 196.65 205.02C196.41 204.87 196.15 204.77 195.88 204.73C195.61 204.68 195.33 204.69 195.06 204.75C194.79 204.81 194.53 204.92 194.3 205.08C194.08 205.24 193.88 205.44 193.73 205.67ZM200.11 185.49C200.32 185.17 200.43 184.8 200.44 184.42C200.45 184.05 200.36 183.67 200.18 183.34C200 183.01 199.73 182.73 199.41 182.54C199.08 182.35 198.71 182.25 198.33 182.25C198.15 182.25 197.98 182.27 197.81 182.31C197.63 182.35 197.47 182.42 197.31 182.51C197.16 182.59 197.01 182.7 196.88 182.82C196.76 182.94 196.64 183.08 196.55 183.23C196.4 183.45 196.3 183.7 196.25 183.97C196.2 184.23 196.21 184.5 196.26 184.76C196.31 185.03 196.41 185.28 196.55 185.5C196.7 185.73 196.89 185.92 197.11 186.08L197.2 186.14C198.18 186.76 199.51 186.44 200.11 185.49Z" /> + <path id="Layer" fill-rule="evenodd" class="shp4" d="M213 224.01C210.76 224.39 209.57 225.15 208.14 226.08C206.98 226.83 205.74 227.64 203.93 228.27C208.47 231.6 214.56 237.87 217.12 242.06C217.52 241.95 217.94 241.89 218.35 241.87C218.77 241.85 219.19 241.87 219.6 241.93C220.01 242 220.42 242.11 220.81 242.25C221.2 242.4 221.58 242.58 221.93 242.81C222.27 243.02 222.59 243.27 222.88 243.55C223.17 243.82 223.44 244.13 223.67 244.46C223.9 244.79 224.1 245.14 224.26 245.51C224.43 245.88 224.55 246.26 224.64 246.65C224.73 247.04 224.78 247.44 224.79 247.84C224.8 248.24 224.77 248.64 224.7 249.03C224.63 249.43 224.52 249.81 224.38 250.19C224.23 250.56 224.05 250.92 223.83 251.25C223.56 251.69 223.23 252.09 222.86 252.44C222.48 252.8 222.07 253.1 221.62 253.35C221.16 253.6 220.68 253.79 220.19 253.92C219.69 254.04 219.17 254.11 218.66 254.1C217.5 254.1 216.37 253.78 215.39 253.16C215.26 253.07 215.18 253.02 215.11 252.97C212.46 251.1 211.75 247.47 213.49 244.71C213.59 244.56 213.69 244.42 213.8 244.28C211.13 239.63 201.64 230.6 199.03 229.96C195.63 229.11 193.67 229.18 191.18 229.27C189.73 229.32 188.08 229.39 185.97 229.27C182.89 229.11 181.2 227.19 179.71 225.49C178.15 223.71 176.65 222.12 173.36 221.82L173.36 217.82C178.5 218.2 180.92 220.79 182.72 222.85C184.08 224.39 184.85 225.2 186.18 225.27C188.12 225.37 189.6 225.32 191.03 225.27C192.77 225.2 194.36 225.15 196.32 225.38C202 225.29 203.92 224.04 205.96 222.72C207.58 221.66 209.41 220.48 212.78 219.99C212.82 219.85 212.87 219.71 212.92 219.58C212.97 219.44 213.02 219.31 213.08 219.17C213.14 219.04 213.2 218.91 213.27 218.78C213.34 218.66 213.41 218.53 213.49 218.41C213.54 218.34 213.59 218.27 213.64 218.19C213.69 218.12 213.74 218.05 213.79 217.98C213.84 217.92 213.9 217.85 213.95 217.78C214.01 217.71 214.07 217.65 214.12 217.58C214.26 217.44 214.41 217.32 214.55 217.19C213.44 213.09 213.24 207.29 214.59 202.77C214.56 202.75 214.54 202.73 214.51 202.71C213.99 202.23 213.56 201.66 213.23 201.03C212.9 200.4 212.69 199.72 212.59 199.01C212.5 198.31 212.53 197.59 212.68 196.9C212.84 196.21 213.11 195.55 213.49 194.95C213.76 194.51 214.09 194.11 214.46 193.76C214.84 193.4 215.26 193.1 215.71 192.85C216.16 192.6 216.64 192.41 217.14 192.28C217.64 192.16 218.15 192.09 218.66 192.1C218.95 192.1 219.24 192.12 219.52 192.16C219.81 192.2 220.09 192.26 220.36 192.34C220.64 192.42 220.91 192.52 221.17 192.63C221.43 192.75 221.69 192.89 221.93 193.04C222.27 193.25 222.59 193.5 222.88 193.78C223.17 194.06 223.44 194.36 223.67 194.69C223.9 195.02 224.1 195.37 224.26 195.74C224.42 196.11 224.55 196.49 224.64 196.88C224.73 197.27 224.78 197.67 224.79 198.07C224.8 198.47 224.77 198.87 224.7 199.27C224.63 199.66 224.52 200.05 224.38 200.42C224.23 200.79 224.05 201.15 223.83 201.49C223.56 201.92 223.23 202.32 222.86 202.68C222.48 203.03 222.07 203.33 221.62 203.58C221.16 203.83 220.68 204.02 220.19 204.15C219.69 204.27 219.17 204.34 218.66 204.34C218.55 204.34 218.44 204.32 218.32 204.31C217.37 207.81 217.58 212.42 218.29 215.58C218.41 215.57 218.54 215.56 218.66 215.56C218.95 215.56 219.24 215.58 219.52 215.62C219.81 215.66 220.09 215.72 220.36 215.8C220.64 215.88 220.91 215.98 221.17 216.1C221.43 216.21 221.69 216.35 221.93 216.5C222.27 216.72 222.59 216.96 222.88 217.24C223.17 217.52 223.44 217.82 223.67 218.15C223.9 218.48 224.1 218.83 224.26 219.2C224.42 219.57 224.55 219.95 224.64 220.35C224.73 220.74 224.78 221.14 224.79 221.54C224.8 221.94 224.77 222.34 224.7 222.73C224.63 223.12 224.52 223.51 224.38 223.88C224.23 224.26 224.05 224.61 223.83 224.95C223.56 225.39 223.23 225.79 222.86 226.14C222.48 226.49 222.07 226.8 221.62 227.05C221.16 227.29 220.68 227.48 220.19 227.61C219.69 227.74 219.17 227.8 218.66 227.8C218.34 227.8 218.03 227.78 217.72 227.73C217.4 227.68 217.1 227.6 216.8 227.51C216.49 227.41 216.2 227.29 215.92 227.15C215.64 227.01 215.37 226.84 215.11 226.66C214.88 226.5 214.65 226.31 214.45 226.12C214.24 225.92 214.04 225.71 213.87 225.49C213.69 225.26 213.52 225.03 213.38 224.78C213.24 224.53 213.11 224.28 213 224.01ZM216.88 197.09C216.74 197.31 216.64 197.56 216.59 197.83C216.54 198.09 216.54 198.36 216.59 198.62C216.64 198.88 216.74 199.14 216.89 199.36C217.03 199.59 217.22 199.78 217.44 199.94L217.53 200C218.51 200.62 219.85 200.3 220.44 199.34C220.52 199.23 220.58 199.11 220.63 198.98C220.68 198.85 220.72 198.72 220.74 198.58C220.77 198.44 220.78 198.31 220.78 198.17C220.77 198.03 220.76 197.89 220.73 197.76C220.6 197.2 220.27 196.73 219.79 196.43C219.45 196.22 219.06 196.1 218.66 196.1C218.49 196.1 218.31 196.13 218.14 196.17C217.97 196.21 217.8 196.28 217.64 196.36C217.49 196.45 217.35 196.55 217.22 196.68C217.09 196.8 216.97 196.93 216.88 197.09ZM216.88 220.55C216.74 220.77 216.64 221.03 216.59 221.29C216.54 221.55 216.54 221.82 216.59 222.09C216.64 222.35 216.74 222.6 216.89 222.82C217.03 223.05 217.22 223.24 217.44 223.4L217.53 223.46C218.51 224.08 219.85 223.76 220.44 222.81C220.52 222.69 220.58 222.57 220.63 222.44C220.68 222.31 220.72 222.18 220.74 222.04C220.77 221.91 220.78 221.77 220.78 221.63C220.77 221.49 220.76 221.36 220.72 221.22C220.6 220.67 220.27 220.2 219.79 219.89C219.45 219.68 219.06 219.57 218.66 219.57C218.49 219.57 218.31 219.59 218.14 219.63C217.97 219.68 217.8 219.74 217.64 219.83C217.49 219.91 217.35 220.02 217.22 220.14C217.09 220.26 216.97 220.4 216.88 220.55ZM216.88 246.85C216.74 247.08 216.64 247.33 216.59 247.59C216.54 247.86 216.54 248.13 216.59 248.39C216.64 248.65 216.74 248.9 216.89 249.13C217.03 249.35 217.22 249.55 217.44 249.7L217.53 249.76C218.51 250.38 219.85 250.06 220.44 249.11C220.52 249 220.58 248.87 220.63 248.74C220.68 248.62 220.72 248.48 220.74 248.35C220.77 248.21 220.78 248.07 220.78 247.94C220.77 247.8 220.76 247.66 220.72 247.53C220.6 246.97 220.27 246.5 219.79 246.2C219.45 245.98 219.06 245.87 218.66 245.87C218.49 245.87 218.31 245.89 218.14 245.94C217.97 245.98 217.8 246.05 217.64 246.13C217.49 246.22 217.35 246.32 217.22 246.44C217.09 246.56 216.97 246.7 216.88 246.85Z" /> + <g id="Layer" style="opacity: 0.251"> + <path id="Layer" class="shp2" d="M151.78 249.54C151.54 249.54 151.3 249.47 151.09 249.32C150.96 249.23 150.85 249.11 150.77 248.98C150.68 248.84 150.63 248.69 150.6 248.54C150.57 248.38 150.58 248.22 150.61 248.06C150.65 247.91 150.71 247.76 150.8 247.63C151.07 247.25 151.32 246.84 151.53 246.41C151.88 245.71 152.14 244.96 152.3 244.2C152.46 243.43 152.52 242.64 152.47 241.86C152.43 241.08 152.28 240.3 152.04 239.56C151.8 238.81 151.46 238.1 151.03 237.45C150.86 237.18 150.8 236.85 150.87 236.54C150.93 236.22 151.12 235.95 151.39 235.78C151.66 235.6 151.99 235.54 152.3 235.61C152.62 235.68 152.89 235.87 153.06 236.14C155.24 239.53 155.48 243.88 153.7 247.48C153.63 247.61 153.56 247.75 153.49 247.88C153.42 248.01 153.34 248.14 153.27 248.27C153.19 248.4 153.11 248.53 153.03 248.65C152.95 248.78 152.86 248.9 152.78 249.03C152.72 249.11 152.66 249.18 152.58 249.24C152.51 249.3 152.43 249.36 152.34 249.4C152.26 249.45 152.16 249.48 152.07 249.5C151.98 249.53 151.88 249.54 151.78 249.54M157.34 252.41C157.12 252.41 156.9 252.35 156.71 252.23C156.52 252.12 156.37 251.95 156.27 251.75C156.17 251.55 156.12 251.33 156.14 251.11C156.16 250.88 156.24 250.67 156.37 250.49C156.82 249.88 157.22 249.21 157.57 248.52C158.11 247.43 158.51 246.26 158.73 245.06C158.96 243.86 159.02 242.64 158.91 241.42C158.8 240.2 158.52 239.01 158.08 237.87C157.64 236.73 157.05 235.65 156.32 234.67C156.13 234.42 156.05 234.09 156.09 233.78C156.14 233.46 156.31 233.18 156.57 232.98C156.82 232.79 157.15 232.71 157.46 232.76C157.78 232.8 158.06 232.98 158.25 233.23C159.12 234.39 159.82 235.66 160.34 237C160.86 238.35 161.18 239.76 161.31 241.2C161.44 242.64 161.37 244.09 161.11 245.5C160.84 246.92 160.38 248.3 159.73 249.59C159.33 250.41 158.85 251.19 158.32 251.92C158.26 252 158.2 252.07 158.13 252.13C158.05 252.19 157.97 252.24 157.89 252.28C157.8 252.33 157.71 252.36 157.62 252.38C157.53 252.4 157.43 252.41 157.34 252.41ZM146.34 246.49C146.13 246.48 145.93 246.43 145.75 246.32C145.57 246.22 145.41 246.06 145.31 245.88C145.2 245.7 145.15 245.49 145.15 245.28C145.14 245.07 145.2 244.87 145.3 244.68C145.49 244.35 145.63 244 145.74 243.64C145.84 243.27 145.9 242.9 145.92 242.52C145.93 242.14 145.9 241.76 145.83 241.39C145.76 241.02 145.64 240.66 145.48 240.31C145.36 240.03 145.36 239.7 145.47 239.41C145.58 239.12 145.81 238.88 146.09 238.75C146.37 238.62 146.7 238.61 146.99 238.71C147.29 238.81 147.53 239.03 147.67 239.31C147.91 239.82 148.09 240.37 148.2 240.92C148.31 241.48 148.35 242.05 148.33 242.62C148.31 243.19 148.21 243.76 148.06 244.3C147.9 244.85 147.68 245.38 147.4 245.88C147.35 245.97 147.28 246.05 147.21 246.13C147.13 246.2 147.05 246.27 146.96 246.32C146.86 246.38 146.76 246.42 146.66 246.45C146.56 246.47 146.45 246.49 146.34 246.49" /> + </g> + <g id="Layer"> + <path id="Layer" class="shp0" d="M151.78 246.54C151.54 246.54 151.3 246.47 151.09 246.32C150.96 246.23 150.85 246.11 150.77 245.98C150.68 245.84 150.63 245.69 150.6 245.54C150.57 245.38 150.58 245.22 150.61 245.06C150.65 244.91 150.71 244.76 150.8 244.63C151.07 244.25 151.32 243.84 151.53 243.41C151.88 242.71 152.14 241.96 152.3 241.2C152.46 240.43 152.52 239.64 152.47 238.86C152.43 238.08 152.28 237.3 152.04 236.56C151.8 235.81 151.46 235.1 151.03 234.45C150.86 234.18 150.8 233.85 150.87 233.54C150.93 233.22 151.12 232.95 151.39 232.78C151.66 232.6 151.99 232.54 152.3 232.61C152.62 232.68 152.89 232.87 153.06 233.14C155.24 236.53 155.48 240.88 153.7 244.48C153.63 244.61 153.56 244.75 153.49 244.88C153.42 245.01 153.34 245.14 153.27 245.27C153.19 245.4 153.11 245.53 153.03 245.65C152.95 245.78 152.86 245.9 152.78 246.03C152.72 246.11 152.66 246.18 152.58 246.24C152.51 246.3 152.43 246.36 152.34 246.4C152.26 246.45 152.16 246.48 152.07 246.5C151.98 246.53 151.88 246.54 151.78 246.54M157.34 249.41C157.12 249.41 156.9 249.35 156.71 249.23C156.52 249.12 156.37 248.95 156.27 248.75C156.17 248.55 156.12 248.33 156.14 248.11C156.16 247.88 156.24 247.67 156.37 247.49C156.82 246.88 157.22 246.21 157.57 245.52C158.11 244.43 158.51 243.26 158.73 242.06C158.96 240.86 159.02 239.64 158.91 238.42C158.8 237.2 158.52 236.01 158.08 234.87C157.64 233.73 157.05 232.65 156.32 231.67C156.13 231.42 156.05 231.09 156.09 230.78C156.14 230.46 156.31 230.18 156.57 229.98C156.82 229.79 157.15 229.71 157.46 229.76C157.78 229.8 158.06 229.98 158.25 230.23C159.12 231.39 159.82 232.66 160.34 234C160.86 235.35 161.18 236.76 161.31 238.2C161.44 239.64 161.37 241.09 161.11 242.5C160.84 243.92 160.38 245.3 159.73 246.59C159.33 247.41 158.85 248.19 158.32 248.92C158.26 249 158.2 249.07 158.13 249.13C158.05 249.19 157.97 249.24 157.89 249.28C157.8 249.33 157.71 249.36 157.62 249.38C157.53 249.4 157.43 249.41 157.34 249.41ZM146.34 243.49C146.13 243.48 145.93 243.43 145.75 243.32C145.57 243.22 145.41 243.06 145.31 242.88C145.2 242.7 145.15 242.49 145.15 242.28C145.14 242.07 145.2 241.87 145.3 241.68C145.49 241.35 145.63 241 145.74 240.64C145.84 240.27 145.9 239.9 145.92 239.52C145.93 239.14 145.9 238.76 145.83 238.39C145.76 238.02 145.64 237.66 145.48 237.31C145.36 237.03 145.36 236.7 145.47 236.41C145.58 236.12 145.81 235.88 146.09 235.75C146.37 235.62 146.7 235.61 146.99 235.71C147.29 235.81 147.53 236.03 147.67 236.31C147.91 236.82 148.09 237.37 148.2 237.92C148.31 238.48 148.35 239.05 148.33 239.62C148.31 240.19 148.21 240.76 148.06 241.3C147.9 241.85 147.68 242.38 147.4 242.88C147.35 242.97 147.28 243.05 147.21 243.13C147.13 243.2 147.05 243.27 146.96 243.32C146.86 243.38 146.76 243.42 146.66 243.45C146.56 243.47 146.45 243.49 146.34 243.49" /> + </g> + <g id="Layer"> + <path id="Layer" fill-rule="evenodd" class="shp5" d="M164.08 192.24C165.2 192.24 167.16 192.28 168.36 192.39L168.36 195.88C167.32 195.78 165.12 195.74 164.08 195.74C154.95 195.74 145.38 199.12 139.72 204.35C135.15 208.57 132.19 214.61 131.03 221.84C130.8 223.29 130.66 225.08 130.6 226.94C130.89 225.07 132.31 223.65 134.03 223.65L135.93 223.65C137.86 223.65 139.42 225.44 139.42 227.65L139.42 257.32C139.42 259.53 137.86 261.32 135.93 261.32L134.03 261.32C132.1 261.32 130.53 259.53 130.53 257.32L130.53 255.72C130.24 255.79 129.94 255.83 129.62 255.83L127.59 255.83C127.06 255.83 126.54 255.73 126.06 255.53C125.57 255.33 125.13 255.03 124.76 254.66C124.39 254.29 124.09 253.85 123.89 253.36C123.69 252.88 123.59 252.36 123.59 251.83L123.59 233.71C123.59 233.24 123.67 232.77 123.84 232.32C124.01 231.88 124.25 231.47 124.56 231.11C124.87 230.75 125.24 230.45 125.66 230.22C126.08 229.99 126.53 229.84 127 229.77C126.82 218.03 130.46 208.14 137.35 201.78C143.72 195.9 153.96 192.24 164.08 192.24ZM129.62 251.43L130.54 251.43L130.54 234.12L129.62 234.12C129.27 234.12 128.93 234.18 128.61 234.32C128.29 234.45 127.99 234.65 127.75 234.89C127.5 235.14 127.31 235.43 127.17 235.75C127.04 236.07 126.97 236.42 126.97 236.77L126.97 248.78C126.97 249.13 127.04 249.47 127.17 249.79C127.31 250.11 127.5 250.41 127.75 250.65C128 250.9 128.29 251.09 128.61 251.23C128.93 251.36 129.27 251.43 129.62 251.43Z" /> + <path id="Layer" fill-rule="evenodd" class="shp4" d="M164.08 189.24C165.2 189.24 167.16 189.28 168.36 189.39L168.36 192.88C167.32 192.78 165.12 192.74 164.08 192.74C154.95 192.74 145.38 196.12 139.72 201.35C135.15 205.57 132.19 211.61 131.03 218.84C130.8 220.29 130.66 222.08 130.6 223.94C130.89 222.07 132.31 220.65 134.03 220.65L135.93 220.65C137.86 220.65 139.42 222.44 139.42 224.65L139.42 254.32C139.42 256.53 137.86 258.32 135.93 258.32L134.03 258.32C132.1 258.32 130.53 256.53 130.53 254.32L130.53 252.72C130.24 252.79 129.94 252.83 129.62 252.83L127.59 252.83C127.06 252.83 126.54 252.73 126.06 252.53C125.57 252.33 125.13 252.03 124.76 251.66C124.39 251.29 124.09 250.85 123.89 250.36C123.69 249.88 123.59 249.36 123.59 248.83L123.59 230.71C123.59 230.24 123.67 229.77 123.84 229.32C124.01 228.88 124.25 228.47 124.56 228.11C124.87 227.75 125.24 227.45 125.66 227.22C126.08 226.99 126.53 226.84 127 226.77C126.82 215.03 130.46 205.14 137.35 198.78C143.72 192.9 153.96 189.24 164.08 189.24ZM129.62 248.43L130.54 248.43L130.54 231.12L129.62 231.12C129.27 231.12 128.93 231.18 128.61 231.32C128.29 231.45 127.99 231.65 127.75 231.89C127.5 232.14 127.31 232.43 127.17 232.75C127.04 233.07 126.97 233.42 126.97 233.77L126.97 245.78C126.97 246.13 127.04 246.47 127.17 246.79C127.31 247.11 127.5 247.41 127.75 247.65C128 247.9 128.29 248.09 128.61 248.23C128.93 248.36 129.27 248.43 129.62 248.43Z" /> + </g> + <g id="Layer"> + <path id="Layer" fill-rule="evenodd" class="shp1" d="M295.75 242.67L295.75 252.82L259.58 252.82L259.58 198.08L272.78 198.08L272.78 242.67L295.75 242.67ZM300.63 201.65C300.63 199.85 301.29 198.37 302.59 197.22C303.89 196.06 305.59 195.49 307.67 195.49C309.75 195.49 311.44 196.06 312.74 197.22C314.04 198.37 314.7 199.85 314.7 201.65C314.7 203.46 314.05 204.94 312.74 206.09C311.44 207.24 309.75 207.82 307.67 207.82C305.59 207.82 303.89 207.24 302.59 206.09C301.29 204.94 300.63 203.46 300.63 201.65ZM314.1 252.82L301.39 252.82L301.39 212.14L314.1 212.14L314.1 252.82ZM341.5 238.8C340.38 238.15 338.23 237.47 335.07 236.77C331.92 236.07 329.31 235.15 327.25 234.01C325.2 232.87 323.63 231.48 322.55 229.85C321.48 228.22 320.94 226.36 320.94 224.25C320.94 220.51 322.48 217.44 325.56 215.02C328.64 212.6 332.68 211.39 337.67 211.39C343.03 211.39 347.34 212.61 350.6 215.04C353.86 217.47 355.49 220.66 355.49 224.63L342.78 224.63C342.78 221.37 341.06 219.74 337.63 219.74C336.3 219.74 335.19 220.11 334.28 220.85C333.38 221.58 332.93 222.51 332.93 223.61C332.93 224.74 333.48 225.65 334.58 226.35C335.69 227.06 337.45 227.63 339.87 228.08C342.29 228.53 344.41 229.07 346.24 229.7C352.36 231.8 355.41 235.58 355.41 241.02C355.41 244.73 353.77 247.75 350.47 250.08C347.17 252.41 342.91 253.57 337.67 253.57C334.18 253.57 331.08 252.95 328.34 251.69C325.61 250.44 323.48 248.74 321.95 246.58C320.42 244.43 319.66 242.16 319.66 239.77L331.5 239.77C331.55 241.66 332.18 243.03 333.38 243.89C334.58 244.76 336.13 245.19 338.01 245.19C339.74 245.19 341.03 244.84 341.9 244.14C342.76 243.44 343.19 242.52 343.19 241.39C343.19 240.32 342.63 239.45 341.5 238.8ZM376.09 212.14L382.79 212.14L382.79 220.94L376.09 220.94L376.09 239.55C376.09 241.08 376.37 242.15 376.92 242.75C377.47 243.35 378.56 243.65 380.19 243.65C381.44 243.65 382.5 243.57 383.35 243.42L383.35 252.49C382.77 252.67 382.19 252.83 381.6 252.96C381.01 253.1 380.42 253.22 379.82 253.31C379.22 253.4 378.62 253.47 378.02 253.51C377.41 253.56 376.81 253.58 376.21 253.57C371.84 253.57 368.62 252.55 366.54 250.49C364.46 248.44 363.42 245.32 363.42 241.13L363.42 220.94L358.23 220.94L358.23 212.14L363.42 212.14L363.42 202.03L376.09 202.03L376.09 212.14ZM392.3 247.99C388.44 244.27 386.51 239.43 386.51 233.46L386.51 232.41C386.51 228.25 387.28 224.58 388.82 221.39C390.36 218.21 392.61 215.75 395.57 214C398.53 212.26 402.04 211.39 406.1 211.39C411.81 211.39 416.32 213.16 419.63 216.71C422.94 220.26 424.59 225.2 424.59 231.54L424.59 236.47L399.41 236.47C399.86 238.75 400.85 240.54 402.38 241.84C403.9 243.15 405.88 243.8 408.32 243.8C412.33 243.8 415.46 242.4 417.72 239.59L423.5 246.43C421.93 248.61 419.69 250.35 416.79 251.64C413.9 252.93 410.78 253.57 407.45 253.57C401.21 253.57 396.16 251.71 392.3 247.99ZM399.4 228.53L412.19 228.53L412.19 227.56C412.24 225.53 411.72 223.95 410.65 222.84C409.57 221.72 408.03 221.17 406.02 221.17C402.31 221.17 400.11 223.62 399.4 228.53ZM441.74 216.92C444.55 213.23 448.42 211.39 453.36 211.39C457.59 211.39 460.76 212.66 462.85 215.19C464.94 217.72 466.03 221.53 466.1 226.62L466.1 252.82L453.39 252.82L453.39 227.14C453.39 225.09 452.98 223.58 452.15 222.61C451.33 221.65 449.82 221.17 447.64 221.17C445.16 221.17 443.32 222.14 442.11 224.1L442.11 252.82L429.44 252.82L429.44 212.14L441.32 212.14L441.74 216.92Z" /> + </g> + <g id="Layer"> + <path id="Layer" fill-rule="evenodd" class="shp0" d="M473.64 198.08L493.3 198.08C500.34 198.08 505.7 199.37 509.37 201.95C513.05 204.54 514.88 208.28 514.88 213.2C514.88 216.03 514.23 218.45 512.93 220.45C511.62 222.46 509.71 223.94 507.18 224.89C510.03 225.64 512.23 227.05 513.75 229.1C515.28 231.15 516.05 233.66 516.05 236.62C516.05 241.98 514.35 246.01 510.95 248.71C507.56 251.4 502.52 252.77 495.86 252.82L473.64 252.82L473.64 198.08ZM486.83 220.9L493.72 220.9C496.6 220.88 498.65 220.35 499.88 219.32C501.11 218.29 501.72 216.77 501.72 214.76C501.72 212.44 501.06 210.78 499.73 209.76C498.4 208.74 496.26 208.23 493.3 208.23L486.83 208.23L486.83 220.9ZM486.83 229.55L486.83 242.67L495.48 242.67C497.86 242.67 499.69 242.13 500.97 241.04C502.25 239.95 502.89 238.41 502.89 236.43C502.89 231.87 500.62 229.58 496.08 229.55L486.83 229.55ZM547.33 223.61L543.15 223.31C539.17 223.31 536.61 224.56 535.48 227.07L535.48 252.82L522.81 252.82L522.81 212.14L534.69 212.14L535.11 217.37C537.24 213.38 540.21 211.39 544.02 211.39C545.37 211.39 546.55 211.54 547.55 211.84L547.33 223.61ZM573.73 252.82C573.28 252 572.88 250.78 572.53 249.18C570.2 252.11 566.94 253.57 562.76 253.57C558.92 253.57 555.66 252.42 552.98 250.1C550.3 247.78 548.96 244.87 548.96 241.36C548.96 236.94 550.59 233.61 553.85 231.35C557.11 229.1 561.84 227.97 568.06 227.97L571.97 227.97L571.97 225.82C571.97 222.06 570.35 220.19 567.12 220.19C564.11 220.19 562.61 221.67 562.61 224.65L549.94 224.65C549.94 220.72 551.61 217.52 554.96 215.07C558.3 212.62 562.57 211.39 567.76 211.39C572.95 211.39 577.04 212.66 580.05 215.19C583.06 217.72 584.6 221.19 584.68 225.6L584.68 243.61C584.73 247.34 585.3 250.2 586.41 252.18L586.41 252.82L573.73 252.82L573.73 252.82ZM569.73 243.54C570.77 242.86 571.52 242.1 571.97 241.24L571.97 234.74L568.28 234.74C563.87 234.74 561.67 236.72 561.67 240.68C561.67 241.83 562.05 242.76 562.83 243.48C563.61 244.19 564.6 244.55 565.8 244.55C567.38 244.55 568.69 244.21 569.73 243.54ZM591.59 201.65C591.59 199.85 592.25 198.37 593.55 197.22C594.85 196.06 596.54 195.49 598.63 195.49C600.7 195.49 602.4 196.06 603.7 197.22C605 198.37 605.65 199.85 605.65 201.65C605.65 203.46 605 204.94 603.7 206.09C602.4 207.24 600.7 207.82 598.63 207.82C596.54 207.82 594.85 207.24 593.55 206.09C592.25 204.94 591.59 203.46 591.59 201.65ZM605.05 252.82L592.35 252.82L592.35 212.14L605.05 212.14L605.05 252.82ZM624.49 212.14L624.91 216.92C627.71 213.23 631.58 211.39 636.52 211.39C640.76 211.39 643.92 212.66 646.02 215.19C648.11 217.72 649.19 221.53 649.27 226.62L649.27 252.82L636.56 252.82L636.56 227.14C636.56 225.09 636.15 223.58 635.32 222.61C634.49 221.65 632.99 221.17 630.81 221.17C628.33 221.17 626.48 222.14 625.28 224.1L625.28 252.82L612.61 252.82L612.61 212.14L624.49 212.14ZM689.76 243.05L689.76 252.82L655.21 252.82L655.21 245.75L673.26 221.92L656 221.92L656 212.14L689.35 212.14L689.35 218.99L671.23 243.05L689.76 243.05Z" /> + </g> +</svg>
\ No newline at end of file diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/NOTICE.md b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/NOTICE.md new file mode 100644 index 0000000000..774e383f8b --- /dev/null +++ b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/NOTICE.md @@ -0,0 +1,23 @@ +# ListenBrainz logo attribution + +The file `ListenBrainz_logo.svg` shipped alongside this plugin is a derivative +work used here under the terms of the Creative Commons Attribution-ShareAlike +4.0 International license (CC BY-SA 4.0). + +## Attribution chain + +1. Original work: [ListenBrainz logo](https://github.com/metabrainz/metabrainz-logos/commit/10127d3e84e5bb7e1c8509f1da12223d19581e18) + by [MonkeyDo](https://github.com/metabrainz/metabrainz-logos/commits?author=MonkeyDo) + at the [MetaBrainz Foundation](https://github.com/metabrainz), licensed under + CC BY-SA 4.0. +2. "ListenBrainz logo for Jellyfin plugin" — derivative by + [lyarenei](https://github.com/lyarenei), distributed in + [jellyfin-plugin-listenbrainz](https://github.com/lyarenei/jellyfin-plugin-listenbrainz/tree/main/res/listenbrainz) + under CC BY-SA 4.0. +3. This redistribution within Jellyfin retains the work unmodified and remains + licensed under CC BY-SA 4.0 per the license's ShareAlike requirement. + +## License + +A full copy of the CC BY-SA 4.0 license is available at +<https://creativecommons.org/licenses/by-sa/4.0/legalcode>. diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/PluginConfiguration.cs new file mode 100644 index 0000000000..6f60d18c33 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/PluginConfiguration.cs @@ -0,0 +1,65 @@ +using MediaBrowser.Model.Plugins; + +namespace MediaBrowser.Providers.Plugins.ListenBrainz.Configuration; + +/// <summary> +/// ListenBrainz plugin configuration. +/// </summary> +public class PluginConfiguration : BasePluginConfiguration +{ + /// <summary> + /// The default Labs API server URL. + /// </summary> + public const string DefaultLabsServer = "https://labs.api.listenbrainz.org"; + + /// <summary> + /// The default rate limit in seconds. + /// </summary> + public const double DefaultRateLimit = 1.0; + + private string _labsServer = DefaultLabsServer; + private double _rateLimit = DefaultRateLimit; + + /// <summary> + /// Gets or sets the Labs API server URL. + /// </summary> + public string LabsServer + { + get => _labsServer; + set => _labsServer = string.IsNullOrWhiteSpace(value) ? DefaultLabsServer : value.TrimEnd('/'); + } + + /// <summary> + /// Gets or sets the similarity algorithm. + /// </summary> + public SimilarityAlgorithm Algorithm { get; set; } = SimilarityAlgorithm.SessionBased1825Days; + + /// <summary> + /// Gets or sets the rate limit in seconds. + /// </summary> + public double RateLimit + { + get => _rateLimit; + set + { + if (value < DefaultRateLimit && _labsServer == DefaultLabsServer) + { + _rateLimit = DefaultRateLimit; + } + else + { + _rateLimit = value; + } + } + } + + /// <summary> + /// Gets or sets the cache duration in days for similar item results. A value of 0 disables caching. + /// </summary> + public int SimilarItemsCacheDays { get; set; } = 14; + + /// <summary> + /// Gets the algorithm string for the API call. + /// </summary> + public string AlgorithmString => Algorithm.ToApiString(); +} diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/SimilarityAlgorithm.cs b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/SimilarityAlgorithm.cs new file mode 100644 index 0000000000..f297d99f6d --- /dev/null +++ b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/SimilarityAlgorithm.cs @@ -0,0 +1,37 @@ +namespace MediaBrowser.Providers.Plugins.ListenBrainz.Configuration; + +/// <summary> +/// Available similarity algorithms for ListenBrainz Labs API. +/// </summary> +public enum SimilarityAlgorithm +{ + /// <summary> + /// Session-based algorithm analyzing ~5 years of listening data. + /// </summary> + SessionBased1825Days = 0, + + /// <summary> + /// Session-based algorithm analyzing ~5 years of listening data (alternate). + /// </summary> + SessionBased1800Days = 1, + + /// <summary> + /// Session-based algorithm analyzing ~20 years of listening data. + /// </summary> + SessionBased7500Days = 2, + + /// <summary> + /// Session-based algorithm analyzing ~20 years with higher contribution threshold. + /// </summary> + SessionBased7500DaysHighContribution = 3, + + /// <summary> + /// Session-based algorithm analyzing ~25 years of listening data. + /// </summary> + SessionBased9000Days = 4, + + /// <summary> + /// Session-based algorithm analyzing ~75 days of recent listening data. + /// </summary> + SessionBased75Days = 5 +} diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/SimilarityAlgorithmExtensions.cs b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/SimilarityAlgorithmExtensions.cs new file mode 100644 index 0000000000..f7874dbae8 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/SimilarityAlgorithmExtensions.cs @@ -0,0 +1,23 @@ +namespace MediaBrowser.Providers.Plugins.ListenBrainz.Configuration; + +/// <summary> +/// Extension methods for <see cref="SimilarityAlgorithm"/>. +/// </summary> +public static class SimilarityAlgorithmExtensions +{ + /// <summary> + /// Gets the API string value for the algorithm. + /// </summary> + /// <param name="algorithm">The algorithm.</param> + /// <returns>The API string value.</returns> + public static string ToApiString(this SimilarityAlgorithm algorithm) => algorithm switch + { + SimilarityAlgorithm.SessionBased1825Days => "session_based_days_1825_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30", + SimilarityAlgorithm.SessionBased1800Days => "session_based_days_1800_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30", + SimilarityAlgorithm.SessionBased7500Days => "session_based_days_7500_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30", + SimilarityAlgorithm.SessionBased7500DaysHighContribution => "session_based_days_7500_session_300_contribution_5_threshold_10_limit_100_filter_True_skip_30", + SimilarityAlgorithm.SessionBased9000Days => "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30", + SimilarityAlgorithm.SessionBased75Days => "session_based_days_75_session_300_contribution_5_threshold_10_limit_100_filter_True_skip_30", + _ => "session_based_days_1825_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30" + }; +} diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html new file mode 100644 index 0000000000..dec21d1b42 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/ListenBrainz/Configuration/config.html @@ -0,0 +1,109 @@ +<!DOCTYPE html> +<html> +<head> + <title>ListenBrainz</title> +</head> +<body> + <div id="configPage" data-role="page" class="page type-interior pluginConfigurationPage configPage" data-require="emby-input,emby-button,emby-select"> + <div data-role="content"> + <div class="content-primary"> + <img id="listenBrainzLogo" alt="ListenBrainz" style="max-width:240px;display:block;margin:0 auto 1em;" /> + <h1>ListenBrainz</h1> + <p>Get similar artist recommendations from ListenBrainz Labs.</p> + <form class="configForm"> + <div class="inputContainer"> + <input is="emby-input" type="text" id="labsServer" required label="Labs API Server" /> + <div class="fieldDescription">The ListenBrainz Labs API server URL. Default: https://labs.api.listenbrainz.org</div> + </div> + <div class="selectContainer"> + <label class="selectLabel" for="algorithm">Similarity Algorithm</label> + <select is="emby-select" id="algorithm" class="emby-select-withcolor"> + <option value="0" selected>~5 years / 1825 days (Recommended)</option> + <option value="1">~5 years / 1800 days</option> + <option value="2">~20 years / 7500 days</option> + <option value="3">~20 years / 7500 days (high contribution)</option> + <option value="4">~25 years / 9000 days</option> + <option value="5">~75 days (recent)</option> + </select> + <div class="fieldDescription">The algorithm used for artist similarity calculation.</div> + </div> + <div class="inputContainer"> + <input is="emby-input" type="number" id="rateLimit" required pattern="[0-9]*" min="0" max="10" step=".01" label="Rate Limit (seconds)" /> + <div class="fieldDescription">Span of time between requests in seconds. The official server is rate limited to one request per second.</div> + </div> + <div class="inputContainer"> + <input is="emby-input" type="number" id="similarItemsCacheDays" required pattern="[0-9]*" min="0" max="365" label="Cache duration (days)" /> + <div class="fieldDescription">Number of days to cache similar artist results from ListenBrainz. Set to 0 to disable caching.</div> + </div> + <br /> + <div> + <button is="emby-button" type="submit" class="raised button-submit block"><span>Save</span></button> + </div> + </form> + <div class="verticalSection" style="margin-top:2em;font-size:0.85em;opacity:0.8;"> + <p>The ListenBrainz logo is © the MetaBrainz Foundation (by MonkeyDo), + adapted for Jellyfin plugin use by + <a href="https://github.com/lyarenei" target="_blank" rel="noopener">lyarenei</a>, + and redistributed here under + <a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank" rel="noopener">CC BY-SA 4.0</a>. + Full attribution notice is shipped alongside the plugin in <code>NOTICE.md</code>.</p> + </div> + </div> + </div> + <script type="text/javascript"> + var ListenBrainzPluginConfig = { + uniquePluginId: "a5b2e8c1-9d4f-4a3b-8c7e-6f1a2b3c4d5e" + }; + + document.querySelector('.configPage') + .addEventListener('pageshow', function () { + Dashboard.showLoadingMsg(); + document.querySelector('#listenBrainzLogo').src = ApiClient.getUrl('web/ConfigurationPage', { name: 'ListenBrainzLogo' }); + ApiClient.getPluginConfiguration(ListenBrainzPluginConfig.uniquePluginId).then(function (config) { + var labsServer = document.querySelector('#labsServer'); + labsServer.value = config.LabsServer; + labsServer.dispatchEvent(new Event('change', { + bubbles: true, + cancelable: false + })); + + document.querySelector('#algorithm').value = config.Algorithm; + + var rateLimit = document.querySelector('#rateLimit'); + rateLimit.value = config.RateLimit; + rateLimit.dispatchEvent(new Event('change', { + bubbles: true, + cancelable: false + })); + + var similarItemsCacheDays = document.querySelector('#similarItemsCacheDays'); + similarItemsCacheDays.value = config.SimilarItemsCacheDays; + similarItemsCacheDays.dispatchEvent(new Event('change', { + bubbles: true, + cancelable: false + })); + + Dashboard.hideLoadingMsg(); + }); + }); + + document.querySelector('.configForm') + .addEventListener('submit', function (e) { + Dashboard.showLoadingMsg(); + + ApiClient.getPluginConfiguration(ListenBrainzPluginConfig.uniquePluginId).then(function (config) { + config.LabsServer = document.querySelector('#labsServer').value; + config.Algorithm = parseInt(document.querySelector('#algorithm').value, 10); + config.RateLimit = document.querySelector('#rateLimit').value; + config.SimilarItemsCacheDays = parseInt(document.querySelector('#similarItemsCacheDays').value, 10); + + ApiClient.updatePluginConfiguration(ListenBrainzPluginConfig.uniquePluginId, config).then(Dashboard.processPluginConfigurationUpdateResult); + }); + + e.preventDefault(); + return false; + }); + </script> + </div> +</body> +</html> diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/ListenBrainzPlugin.cs b/MediaBrowser.Providers/Plugins/ListenBrainz/ListenBrainzPlugin.cs new file mode 100644 index 0000000000..efac93f94e --- /dev/null +++ b/MediaBrowser.Providers/Plugins/ListenBrainz/ListenBrainzPlugin.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Model.Plugins; +using MediaBrowser.Model.Serialization; +using MediaBrowser.Providers.Plugins.ListenBrainz.Configuration; + +namespace MediaBrowser.Providers.Plugins.ListenBrainz; + +/// <summary> +/// ListenBrainz plugin instance. +/// </summary> +public class ListenBrainzPlugin : BasePlugin<PluginConfiguration>, IHasWebPages +{ + /// <summary> + /// Initializes a new instance of the <see cref="ListenBrainzPlugin"/> class. + /// </summary> + /// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param> + /// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param> + public ListenBrainzPlugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + } + + /// <summary> + /// Gets the current plugin instance. + /// </summary> + public static ListenBrainzPlugin? Instance { get; private set; } + + /// <inheritdoc /> + public override Guid Id => new("a5b2e8c1-9d4f-4a3b-8c7e-6f1a2b3c4d5e"); + + /// <inheritdoc /> + public override string Name => "ListenBrainz Similarity Provider"; + + /// <inheritdoc /> + public override string Description => "Get similar artist recommendations from ListenBrainz Labs."; + + /// <inheritdoc /> + public override string ConfigurationFileName => "Jellyfin.Plugin.ListenBrainz.xml"; + + /// <inheritdoc /> + public IEnumerable<PluginPageInfo> GetPages() + { + var resourcePrefix = GetType().Namespace + ".Configuration."; + yield return new PluginPageInfo + { + Name = Name, + EmbeddedResourcePath = resourcePrefix + "config.html" + }; + yield return new PluginPageInfo + { + Name = Name + "Logo", + EmbeddedResourcePath = resourcePrefix + "ListenBrainz_logo.svg" + }; + yield return new PluginPageInfo + { + Name = Name + "Notice", + EmbeddedResourcePath = resourcePrefix + "NOTICE.md" + }; + } +} diff --git a/MediaBrowser.Providers/Plugins/ListenBrainz/ListenBrainzSimilarArtistProvider.cs b/MediaBrowser.Providers/Plugins/ListenBrainz/ListenBrainzSimilarArtistProvider.cs new file mode 100644 index 0000000000..3dca748d06 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/ListenBrainz/ListenBrainzSimilarArtistProvider.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Threading; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; +using MediaBrowser.Providers.Plugins.ListenBrainz.Api; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.Plugins.ListenBrainz; + +/// <summary> +/// ListenBrainz-based similar items provider for music artists. +/// </summary> +public class ListenBrainzSimilarArtistProvider : IRemoteSimilarItemsProvider<MusicArtist> +{ + private readonly ListenBrainzLabsClient _labsClient; + private readonly ILogger<ListenBrainzSimilarArtistProvider> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="ListenBrainzSimilarArtistProvider"/> class. + /// </summary> + /// <param name="labsClient">The ListenBrainz Labs API client.</param> + /// <param name="logger">The logger.</param> + public ListenBrainzSimilarArtistProvider( + ListenBrainzLabsClient labsClient, + ILogger<ListenBrainzSimilarArtistProvider> logger) + { + _labsClient = labsClient; + _logger = logger; + } + + /// <inheritdoc/> + public string Name => "ListenBrainz"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.SimilarityProvider; + + /// <inheritdoc/> + public TimeSpan? CacheDuration + { + get + { + var days = ListenBrainzPlugin.Instance?.Configuration.SimilarItemsCacheDays ?? 0; + return days > 0 ? TimeSpan.FromDays(days) : null; + } + } + + /// <inheritdoc/> + public async IAsyncEnumerable<SimilarItemReference> GetSimilarItemsAsync( + MusicArtist item, + SimilarItemsQuery query, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(item); + ArgumentNullException.ThrowIfNull(query); + + if (!item.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out var mbidStr) || !Guid.TryParse(mbidStr, out var mbid)) + { + _logger.LogDebug("No MusicBrainz Artist ID found for {ArtistName}", item.Name); + yield break; + } + + IReadOnlyList<Guid> similarMbids; + try + { + similarMbids = await _labsClient.GetSimilarArtistsAsync(mbid, cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, "Failed to fetch similar artists from ListenBrainz for {ArtistMbid}", mbid); + yield break; + } + + var providerName = MetadataProvider.MusicBrainzArtist.ToString(); + + foreach (var similarMbid in similarMbids) + { + yield return new SimilarItemReference + { + ProviderName = providerName, + ProviderId = similarMbid.ToString() + }; + } + } +} diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs index f11b1d95aa..78405c21fc 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs @@ -77,5 +77,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// Gets or sets a value indicating the still image size to fetch. /// </summary> public string? StillSize { get; set; } + + /// <summary> + /// Gets or sets the cache duration in days for similar item results. A value of 0 disables caching. + /// </summary> + public int SimilarItemsCacheDays { get; set; } = 7; } } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html index 89d380ec1f..4048fc1655 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html +++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html @@ -44,6 +44,13 @@ <span>Hide crew members without profile images.</span> </label> </div> + <div class="verticalSection"> + <h2>Similar Items</h2> + <div class="inputContainer"> + <input is="emby-input" type="number" id="similarItemsCacheDays" pattern="[0-9]*" required min="0" max="365" label="Cache duration (days)" /> + <div class="fieldDescription">Number of days to cache similar item results from TMDb. Set to 0 to disable caching.</div> + </div> + </div> <div class="verticalSection verticalSection-extrabottompadding"> <h2>Image Scaling</h2> <div class="selectContainer"> @@ -161,6 +168,13 @@ cancelable: false })); + var similarItemsCacheDays = document.querySelector('#similarItemsCacheDays'); + similarItemsCacheDays.value = config.SimilarItemsCacheDays; + similarItemsCacheDays.dispatchEvent(new Event('change', { + bubbles: true, + cancelable: false + })); + pluginConfig = config; configureImageScaling(); }); @@ -179,6 +193,7 @@ config.MaxCrewMembers = document.querySelector('#maxCrewMembers').value; config.HideMissingCastMembers = document.querySelector('#hideMissingCastMembers').checked; config.HideMissingCrewMembers = document.querySelector('#hideMissingCrewMembers').checked; + config.SimilarItemsCacheDays = parseInt(document.querySelector('#similarItemsCacheDays').value, 10); config.PosterSize = document.querySelector('#selectPosterSize').value; config.BackdropSize = document.querySelector('#selectBackdropSize').value; config.LogoSize = document.querySelector('#selectLogoSize').value; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs new file mode 100644 index 0000000000..5206de78ce --- /dev/null +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Threading; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging; +using Movie = MediaBrowser.Controller.Entities.Movies.Movie; + +namespace MediaBrowser.Providers.Plugins.Tmdb.Movies; + +/// <summary> +/// TMDb-based similar items provider for movies. +/// </summary> +public class TmdbMovieSimilarProvider : IRemoteSimilarItemsProvider<Movie> +{ + private readonly TmdbClientManager _tmdbClientManager; + private readonly ILogger<TmdbMovieSimilarProvider> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="TmdbMovieSimilarProvider"/> class. + /// </summary> + /// <param name="tmdbClientManager">The TMDb client manager.</param> + /// <param name="logger">The logger.</param> + public TmdbMovieSimilarProvider(TmdbClientManager tmdbClientManager, ILogger<TmdbMovieSimilarProvider> logger) + { + _tmdbClientManager = tmdbClientManager; + _logger = logger; + } + + /// <inheritdoc/> + public string Name => TmdbUtils.ProviderName; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.SimilarityProvider; + + /// <inheritdoc/> + public TimeSpan? CacheDuration + { + get + { + var days = Plugin.Instance?.Configuration.SimilarItemsCacheDays ?? 0; + return days > 0 ? TimeSpan.FromDays(days) : null; + } + } + + /// <inheritdoc/> + public async IAsyncEnumerable<SimilarItemReference> GetSimilarItemsAsync( + Movie item, + SimilarItemsQuery query, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + if (!item.TryGetProviderId(MetadataProvider.Tmdb, out var tmdbIdStr) || !int.TryParse(tmdbIdStr, CultureInfo.InvariantCulture, out var tmdbId)) + { + yield break; + } + + var providerName = MetadataProvider.Tmdb.ToString(); + var page = 0; + var totalPages = 1; + + while (page <= totalPages && !cancellationToken.IsCancellationRequested) + { + IReadOnlyList<TMDbLib.Objects.Search.SearchMovie> pageResults; + try + { + (pageResults, totalPages) = await _tmdbClientManager + .GetMovieSimilarPageAsync(tmdbId, page, TmdbUtils.GetImageLanguagesParam(string.Empty), cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get similar movies from TMDb for {TmdbId} page {Page}", tmdbId, page); + yield break; + } + + if (pageResults.Count == 0) + { + yield break; + } + + foreach (var similar in pageResults) + { + yield return new SimilarItemReference + { + ProviderName = providerName, + ProviderId = similar.Id.ToString(CultureInfo.InvariantCulture) + }; + } + + page++; + } + } +} diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs new file mode 100644 index 0000000000..c85718b993 --- /dev/null +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Threading; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Providers.Plugins.Tmdb.TV; + +/// <summary> +/// TMDb-based similar items provider for TV series. +/// </summary> +public class TmdbSeriesSimilarProvider : IRemoteSimilarItemsProvider<Series> +{ + private readonly TmdbClientManager _tmdbClientManager; + private readonly ILogger<TmdbSeriesSimilarProvider> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="TmdbSeriesSimilarProvider"/> class. + /// </summary> + /// <param name="tmdbClientManager">The TMDb client manager.</param> + /// <param name="logger">The logger.</param> + public TmdbSeriesSimilarProvider(TmdbClientManager tmdbClientManager, ILogger<TmdbSeriesSimilarProvider> logger) + { + _tmdbClientManager = tmdbClientManager; + _logger = logger; + } + + /// <inheritdoc/> + public string Name => TmdbUtils.ProviderName; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.SimilarityProvider; + + /// <inheritdoc/> + public TimeSpan? CacheDuration + { + get + { + var days = Plugin.Instance?.Configuration.SimilarItemsCacheDays ?? 0; + return days > 0 ? TimeSpan.FromDays(days) : null; + } + } + + /// <inheritdoc/> + public async IAsyncEnumerable<SimilarItemReference> GetSimilarItemsAsync( + Series item, + SimilarItemsQuery query, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + if (!item.TryGetProviderId(MetadataProvider.Tmdb, out var tmdbIdStr) || !int.TryParse(tmdbIdStr, CultureInfo.InvariantCulture, out var tmdbId)) + { + yield break; + } + + var providerName = MetadataProvider.Tmdb.ToString(); + var page = 1; + var totalPages = 1; + + while (page <= totalPages && !cancellationToken.IsCancellationRequested) + { + IReadOnlyList<TMDbLib.Objects.Search.SearchTv> pageResults; + try + { + (pageResults, totalPages) = await _tmdbClientManager + .GetSeriesSimilarPageAsync(tmdbId, page, TmdbUtils.GetImageLanguagesParam(string.Empty), cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get similar TV shows from TMDb for {TmdbId} page {Page}", tmdbId, page); + yield break; + } + + if (pageResults.Count == 0) + { + yield break; + } + + foreach (var similar in pageResults) + { + yield return new SimilarItemReference + { + ProviderName = providerName, + ProviderId = similar.Id.ToString(CultureInfo.InvariantCulture) + }; + } + + page++; + } + } +} diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs index 274db347ba..174f1546a7 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs @@ -505,6 +505,54 @@ namespace MediaBrowser.Providers.Plugins.Tmdb } /// <summary> + /// Gets a single page of similar movies for a movie from the TMDb API. + /// </summary> + /// <param name="tmdbId">The TMDb id of the movie.</param> + /// <param name="page">The page number to fetch (1-based).</param> + /// <param name="language">The language for results.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>A tuple containing the list of similar movies and the total number of pages available.</returns> + public async Task<(IReadOnlyList<SearchMovie> Results, int TotalPages)> GetMovieSimilarPageAsync(int tmdbId, int page, string? language, CancellationToken cancellationToken) + { + await EnsureClientConfigAsync().ConfigureAwait(false); + + var searchResults = await _tmDbClient + .GetMovieSimilarAsync(tmdbId, language, page, cancellationToken) + .ConfigureAwait(false); + + if (searchResults?.Results is null || searchResults.Results.Count == 0) + { + return ([], 0); + } + + return (searchResults.Results, searchResults.TotalPages); + } + + /// <summary> + /// Gets a single page of similar TV shows for a series from the TMDb API. + /// </summary> + /// <param name="tmdbId">The TMDb id of the TV show.</param> + /// <param name="page">The page number to fetch (1-based).</param> + /// <param name="language">The language for results.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>A tuple containing the list of similar TV shows and the total number of pages available.</returns> + public async Task<(IReadOnlyList<SearchTv> Results, int TotalPages)> GetSeriesSimilarPageAsync(int tmdbId, int page, string? language, CancellationToken cancellationToken) + { + await EnsureClientConfigAsync().ConfigureAwait(false); + + var searchResults = await _tmDbClient + .GetTvShowSimilarAsync(tmdbId, language, page, cancellationToken) + .ConfigureAwait(false); + + if (searchResults?.Results is null || searchResults.Results.Count == 0) + { + return ([], 0); + } + + return (searchResults.Results, searchResults.TotalPages); + } + + /// <summary> /// Handles bad path checking and builds the absolute url. /// </summary> /// <param name="size">The image size to fetch.</param> diff --git a/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<Guid> 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/JellyfinDbContext.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs index 83c15aa647..b0c12bf592 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs @@ -299,6 +299,11 @@ public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILog }); return result; } + catch (DbUpdateConcurrencyException) + { + // a concurrency exception is supposed to be always handled by the invoker of the method, logging it here is only causing log bloat. + throw; + } catch (Exception e) { logger.LogError(e, "Error trying to save changes."); 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. /// </summary> /// <param name="StreamType">The type of media stream to match (Audio, Subtitle, etc.).</param> -/// <param name="Language">The language to match.</param> +/// <param name="Language">List of languages to match.</param> /// <param name="IsExternal">If not null, filters by internal (false) or external (true) streams. Only applicable to subtitles.</param> public sealed record HasMediaStreamType( MediaStreamTypeEntity StreamType, - string Language, - bool? IsExternal = null) : FolderMatchCriteria; + IReadOnlyCollection<string> Language, + bool? IsExternal = null) : FolderMatchCriteria +{ + /// <summary> + /// Initializes a new instance of the <see cref="HasMediaStreamType"/> class. + /// </summary> + /// <param name="StreamType">The type of media stream to match (Audio, Subtitle, etc.).</param> + /// <param name="Language">The language to match.</param> + /// <param name="IsExternal">If not null, filters by internal (false) or external (true) streams. Only applicable to subtitles.</param> + public HasMediaStreamType( + MediaStreamTypeEntity StreamType, + string Language, + bool? IsExternal = null) : this(StreamType, [Language], IsExternal) + { + } +} diff --git a/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs b/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs index 2fb45600b1..b29c64f50d 100644 --- a/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.Linq; using Emby.Naming.Common; using Emby.Naming.Video; +using Jellyfin.Data.Enums; +using MediaBrowser.Model.Entities; using Xunit; namespace Jellyfin.Naming.Tests.Video @@ -10,6 +12,12 @@ namespace Jellyfin.Naming.Tests.Video public class MultiVersionTests { private readonly NamingOptions _namingOptions = new NamingOptions(); + private readonly VideoListResolver _videoListResolver; + + public MultiVersionTests() + { + _videoListResolver = new VideoListResolver(_namingOptions); + } [Fact] public void TestMultiEdition1() @@ -22,9 +30,8 @@ namespace Jellyfin.Naming.Tests.Video "/movies/X-Men Days of Future Past/X-Men Days of Future Past [hsbs].mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result, v => v.ExtraType is null); Assert.Single(result, v => v.ExtraType is not null); @@ -41,9 +48,8 @@ namespace Jellyfin.Naming.Tests.Video "/movies/X-Men Days of Future Past/X-Men Days of Future Past [banana].mp4" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result, v => v.ExtraType is null); Assert.Single(result, v => v.ExtraType is not null); @@ -59,9 +65,8 @@ namespace Jellyfin.Naming.Tests.Video "/movies/The Phantom of the Opera (1925)/The Phantom of the Opera (1925) - 1929 version.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result); Assert.Single(result[0].AlternateVersions); @@ -81,9 +86,8 @@ namespace Jellyfin.Naming.Tests.Video "/movies/M/Movie 7.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(7, result.Count); Assert.Empty(result[0].AlternateVersions); @@ -104,9 +108,8 @@ namespace Jellyfin.Naming.Tests.Video "/movies/Movie/Movie-8.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result); Assert.Equal(7, result[0].AlternateVersions.Count); @@ -128,9 +131,8 @@ namespace Jellyfin.Naming.Tests.Video "/movies/Mo/Movie 9.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(9, result.Count); Assert.Empty(result[0].AlternateVersions); @@ -148,9 +150,8 @@ namespace Jellyfin.Naming.Tests.Video "/movies/Movie/Movie 5.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(5, result.Count); Assert.Empty(result[0].AlternateVersions); @@ -170,9 +171,8 @@ namespace Jellyfin.Naming.Tests.Video "/movies/Iron Man/Iron Man (2011).mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(5, result.Count); Assert.Empty(result[0].AlternateVersions); @@ -192,19 +192,18 @@ namespace Jellyfin.Naming.Tests.Video "/movies/Iron Man/Iron Man[test].mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result); Assert.Equal("/movies/Iron Man/Iron Man.mkv", result[0].Files[0].Path); Assert.Equal(6, result[0].AlternateVersions.Count); - Assert.Equal("/movies/Iron Man/Iron Man-720p.mkv", result[0].AlternateVersions[0].Path); - Assert.Equal("/movies/Iron Man/Iron Man-3d.mkv", result[0].AlternateVersions[1].Path); - Assert.Equal("/movies/Iron Man/Iron Man-3d-hsbs.mkv", result[0].AlternateVersions[2].Path); - Assert.Equal("/movies/Iron Man/Iron Man-bluray.mkv", result[0].AlternateVersions[3].Path); - Assert.Equal("/movies/Iron Man/Iron Man-test.mkv", result[0].AlternateVersions[4].Path); - Assert.Equal("/movies/Iron Man/Iron Man[test].mkv", result[0].AlternateVersions[5].Path); + Assert.Equal("/movies/Iron Man/Iron Man-720p.mkv", result[0].AlternateVersions[0].Files[0].Path); + Assert.Equal("/movies/Iron Man/Iron Man-3d.mkv", result[0].AlternateVersions[1].Files[0].Path); + Assert.Equal("/movies/Iron Man/Iron Man-3d-hsbs.mkv", result[0].AlternateVersions[2].Files[0].Path); + Assert.Equal("/movies/Iron Man/Iron Man-bluray.mkv", result[0].AlternateVersions[3].Files[0].Path); + Assert.Equal("/movies/Iron Man/Iron Man-test.mkv", result[0].AlternateVersions[4].Files[0].Path); + Assert.Equal("/movies/Iron Man/Iron Man[test].mkv", result[0].AlternateVersions[5].Files[0].Path); } [Fact] @@ -221,19 +220,18 @@ namespace Jellyfin.Naming.Tests.Video "/movies/Iron Man/Iron Man [test].mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result); Assert.Equal("/movies/Iron Man/Iron Man.mkv", result[0].Files[0].Path); Assert.Equal(6, result[0].AlternateVersions.Count); - Assert.Equal("/movies/Iron Man/Iron Man - 720p.mkv", result[0].AlternateVersions[0].Path); - Assert.Equal("/movies/Iron Man/Iron Man - 3d.mkv", result[0].AlternateVersions[1].Path); - Assert.Equal("/movies/Iron Man/Iron Man - 3d-hsbs.mkv", result[0].AlternateVersions[2].Path); - Assert.Equal("/movies/Iron Man/Iron Man - bluray.mkv", result[0].AlternateVersions[3].Path); - Assert.Equal("/movies/Iron Man/Iron Man - test.mkv", result[0].AlternateVersions[4].Path); - Assert.Equal("/movies/Iron Man/Iron Man [test].mkv", result[0].AlternateVersions[5].Path); + Assert.Equal("/movies/Iron Man/Iron Man - 720p.mkv", result[0].AlternateVersions[0].Files[0].Path); + Assert.Equal("/movies/Iron Man/Iron Man - 3d.mkv", result[0].AlternateVersions[1].Files[0].Path); + Assert.Equal("/movies/Iron Man/Iron Man - 3d-hsbs.mkv", result[0].AlternateVersions[2].Files[0].Path); + Assert.Equal("/movies/Iron Man/Iron Man - bluray.mkv", result[0].AlternateVersions[3].Files[0].Path); + Assert.Equal("/movies/Iron Man/Iron Man - test.mkv", result[0].AlternateVersions[4].Files[0].Path); + Assert.Equal("/movies/Iron Man/Iron Man [test].mkv", result[0].AlternateVersions[5].Files[0].Path); } [Fact] @@ -245,9 +243,8 @@ namespace Jellyfin.Naming.Tests.Video "/movies/Iron Man/Iron Man - C (2007).mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(2, result.Count); } @@ -266,17 +263,16 @@ namespace Jellyfin.Naming.Tests.Video "/movies/Iron Man/Iron Man_3d.hsbs.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result); Assert.Equal(6, result[0].AlternateVersions.Count); // Verify 3D recognition is preserved on alternate versions - var hsbs = result[0].AlternateVersions.First(v => v.Path.Contains("3d-hsbs", StringComparison.Ordinal)); - Assert.True(hsbs.Is3D); - Assert.Equal("hsbs", hsbs.Format3D); + var hsbs = result[0].AlternateVersions.First(v => v.Files[0].Path.Contains("3d-hsbs", StringComparison.Ordinal)); + Assert.True(hsbs.Files[0].Is3D); + Assert.Equal("hsbs", hsbs.Files[0].Format3D); } [Fact] @@ -293,9 +289,8 @@ namespace Jellyfin.Naming.Tests.Video "/movies/Iron Man/Iron Man (2011).mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(5, result.Count); Assert.Empty(result[0].AlternateVersions); @@ -310,9 +305,8 @@ namespace Jellyfin.Naming.Tests.Video "/movies/Blade Runner (1982)/Blade Runner (1982) [EE by ADM] [480p HEVC AAC,AAC,AAC].mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result); Assert.Single(result[0].AlternateVersions); @@ -327,9 +321,8 @@ namespace Jellyfin.Naming.Tests.Video "/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) [2160p] Blu-ray.x265.AAC.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result); Assert.Single(result[0].AlternateVersions); @@ -348,18 +341,17 @@ namespace Jellyfin.Naming.Tests.Video "/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016).mkv", }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result); Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016).mkv", result[0].Files[0].Path); Assert.Equal(5, result[0].AlternateVersions.Count); - Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 2160p.mkv", result[0].AlternateVersions[0].Path); - Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p.mkv", result[0].AlternateVersions[1].Path); - Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 720p.mkv", result[0].AlternateVersions[2].Path); - Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Directors Cut.mkv", result[0].AlternateVersions[3].Path); - Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Theatrical Release.mkv", result[0].AlternateVersions[4].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 2160p.mkv", result[0].AlternateVersions[0].Files[0].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p.mkv", result[0].AlternateVersions[1].Files[0].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 720p.mkv", result[0].AlternateVersions[2].Files[0].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Directors Cut.mkv", result[0].AlternateVersions[3].Files[0].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Theatrical Release.mkv", result[0].AlternateVersions[4].Files[0].Path); } [Fact] @@ -381,24 +373,23 @@ namespace Jellyfin.Naming.Tests.Video "/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016).mkv", }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result); Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016).mkv", result[0].Files[0].Path); Assert.Equal(11, result[0].AlternateVersions.Count); - Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 2160p.mkv", result[0].AlternateVersions[0].Path); - Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 2160p Remux.mkv", result[0].AlternateVersions[1].Path); - Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p.mkv", result[0].AlternateVersions[2].Path); - Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p Directors Cut.mkv", result[0].AlternateVersions[3].Path); - Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p High Bitrate.mkv", result[0].AlternateVersions[4].Path); - Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p Remux.mkv", result[0].AlternateVersions[5].Path); - Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p Theatrical Release.mkv", result[0].AlternateVersions[6].Path); - Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 720p.mkv", result[0].AlternateVersions[7].Path); - Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 720p Directors Cut.mkv", result[0].AlternateVersions[8].Path); - Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Directors Cut.mkv", result[0].AlternateVersions[9].Path); - Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Theatrical Release.mkv", result[0].AlternateVersions[10].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 2160p.mkv", result[0].AlternateVersions[0].Files[0].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 2160p Remux.mkv", result[0].AlternateVersions[1].Files[0].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p.mkv", result[0].AlternateVersions[2].Files[0].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p Directors Cut.mkv", result[0].AlternateVersions[3].Files[0].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p High Bitrate.mkv", result[0].AlternateVersions[4].Files[0].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p Remux.mkv", result[0].AlternateVersions[5].Files[0].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p Theatrical Release.mkv", result[0].AlternateVersions[6].Files[0].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 720p.mkv", result[0].AlternateVersions[7].Files[0].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 720p Directors Cut.mkv", result[0].AlternateVersions[8].Files[0].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Directors Cut.mkv", result[0].AlternateVersions[9].Files[0].Path); + Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Theatrical Release.mkv", result[0].AlternateVersions[10].Files[0].Path); } [Fact] @@ -410,9 +401,8 @@ namespace Jellyfin.Naming.Tests.Video "/movies/John Wick - Kapitel 3 (2019) [imdbid=tt6146586]/John Wick - Kapitel 3 (2019) [imdbid=tt6146586] - Version 2.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result); Assert.Single(result[0].AlternateVersions); @@ -427,9 +417,8 @@ namespace Jellyfin.Naming.Tests.Video "/movies/John Wick - Chapter 3 (2019)/John Wick - Chapter 3 (2019) [Version 2.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(2, result.Count); } @@ -437,7 +426,7 @@ namespace Jellyfin.Naming.Tests.Video [Fact] public void TestEmptyList() { - var result = VideoListResolver.Resolve(new List<VideoFileInfo>(), _namingOptions).ToList(); + var result = _videoListResolver.Resolve(new List<VideoFileInfo>()).ToList(); Assert.Empty(result); } @@ -451,9 +440,8 @@ namespace Jellyfin.Naming.Tests.Video "/movies/Movie (2020)/Movie (2020)_1080p.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result); Assert.Single(result[0].AlternateVersions); @@ -468,11 +456,678 @@ namespace Jellyfin.Naming.Tests.Video "/movies/Movie (2020)/Movie (2020).1080p.mkv" }; - var result = VideoListResolver.Resolve( + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); + + Assert.Single(result); + Assert.Single(result[0].AlternateVersions); + } + + // Episode multi-version tests + + [Fact] + public void TestMultiVersionEpisodeInOwnFolder() + { + // Two versions of S01E01 in their own subfolder should merge + var files = new[] + { + "/TV/Dexter/Dexter - S01E01/Dexter - S01E01 - 1080p.mkv", + "/TV/Dexter/Dexter - S01E01/Dexter - S01E01 - 720p.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Single(result); + Assert.Single(result[0].AlternateVersions); + // 1080p should be primary (higher resolution) + Assert.Contains("1080p", result[0].Files[0].Path, StringComparison.Ordinal); + Assert.Contains("720p", result[0].AlternateVersions[0].Files[0].Path, StringComparison.Ordinal); + } + + [Fact] + public void TestMultiVersionEpisodeMixedSeasonFolder() + { + // Multiple episodes in season folder, some with versions + var files = new[] + { + "/TV/Dexter/Season 1/Dexter - S01E01 - 1080p.mkv", + "/TV/Dexter/Season 1/Dexter - S01E01 - 720p.mkv", + "/TV/Dexter/Season 1/Dexter - S01E02.mkv", + "/TV/Dexter/Season 1/Dexter - S01E03 - 1080p.mkv", + "/TV/Dexter/Season 1/Dexter - S01E03 - 720p.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Equal(3, result.Count); + + // S01E01 - should have one alternate version + var e01 = result.FirstOrDefault(r => r.Files[0].Path.Contains("S01E01", StringComparison.Ordinal)); + Assert.NotNull(e01); + Assert.Single(e01!.AlternateVersions); + Assert.Contains("1080p", e01.Files[0].Path, StringComparison.Ordinal); + + // S01E02 - standalone, no alternates + var e02 = result.FirstOrDefault(r => r.Files[0].Path.Contains("S01E02", StringComparison.Ordinal)); + Assert.NotNull(e02); + Assert.Empty(e02!.AlternateVersions); + + // S01E03 - should have one alternate version + var e03 = result.FirstOrDefault(r => r.Files[0].Path.Contains("S01E03", StringComparison.Ordinal)); + Assert.NotNull(e03); + Assert.Single(e03!.AlternateVersions); + } + + [Fact] + public void TestMultiVersionEpisodeDontCollapse() + { + // Different episodes should NOT collapse into versions + var files = new[] + { + "/TV/Dexter/Season 1/Dexter - S01E01.mkv", + "/TV/Dexter/Season 1/Dexter - S01E02.mkv", + "/TV/Dexter/Season 1/Dexter - S01E03.mkv", + "/TV/Dexter/Season 1/Dexter - S01E04.mkv", + "/TV/Dexter/Season 1/Dexter - S01E05.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Equal(5, result.Count); + Assert.All(result, r => Assert.Empty(r.AlternateVersions)); + } + + [Fact] + public void TestMultiVersionEpisodeWithVersionSuffix() + { + // Episodes with named versions (like Aired/Uncensored) + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01 - Aired.mkv", + "/TV/Show/Season 1/Show - S01E01 - Uncensored.mkv", + "/TV/Show/Season 1/Show - S01E02 - Aired.mkv", + "/TV/Show/Season 1/Show - S01E02 - Uncensored.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Equal(2, result.Count); + Assert.All(result, r => Assert.Single(r.AlternateVersions)); + } + + [Fact] + public void TestMultiVersionEpisodeFourVersions() + { + // Four versions of the same episode + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01 - VersionA.mkv", + "/TV/Show/Season 1/Show - S01E01 - VersionB.mkv", + "/TV/Show/Season 1/Show - S01E01 - VersionC.mkv", + "/TV/Show/Season 1/Show - S01E01 - VersionD.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Single(result); + Assert.Equal(3, result[0].AlternateVersions.Count); + } + + [Fact] + public void TestMultiVersionEpisodeWithResolutions() + { + // Resolution sorting should work for episodes too + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01 - 720p.mkv", + "/TV/Show/Season 1/Show - S01E01 - 2160p.mkv", + "/TV/Show/Season 1/Show - S01E01 - 1080p.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Single(result); + Assert.Equal(2, result[0].AlternateVersions.Count); + // Primary should be 2160p (highest resolution) + Assert.Contains("2160p", result[0].Files[0].Path, StringComparison.Ordinal); + // Next should be 1080p, then 720p + Assert.Contains("1080p", result[0].AlternateVersions[0].Files[0].Path, StringComparison.Ordinal); + Assert.Contains("720p", result[0].AlternateVersions[1].Files[0].Path, StringComparison.Ordinal); + } + + [Fact] + public void TestMultiVersionEpisodeDifferentSeasons() + { + // Same episode number but different seasons should NOT group + var files = new[] + { + "/TV/Show/Show - S01E01.mkv", + "/TV/Show/Show - S02E01.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Equal(2, result.Count); + Assert.All(result, r => Assert.Empty(r.AlternateVersions)); + } + + [Fact] + public void TestMultiVersionEpisodeDisabledByDefault() + { + // Without collectionType: CollectionType.tvshows, episodes should NOT group + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01 - 1080p.mkv", + "/TV/Show/Season 1/Show - S01E01 - 720p.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); + + // Without the tvshows collection type, these fall through the movie path + // (folder-name eligibility fails) and are treated as separate items. + Assert.Equal(2, result.Count); + } + + [Fact] + public void TestMultiVersionEpisodeSameNumberDifferentTitle() + { + // Two files parse to the same S01E01 but carry distinct episode titles. + // Current behavior: they are grouped as alternate versions because + // grouping keys only on season + episode number, not on episode title. + // This documents the trade-off: users with mis-numbered episodes will + // see one of the files collapsed into AlternateVersions of the other. + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01 - Pilot.mkv", + "/TV/Show/Season 1/Show - S01E01 - Completely Different Title.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Single(result); + Assert.Single(result[0].AlternateVersions); + } + + [Fact] + public void TestMultiVersionEpisodeWithTitle() + { + // Episodes with an episode title AND a version suffix should group + var files = new[] + { + "/TV/Show/Show - S01E01/Show - S01E01 - Episode Title - 1080p.mkv", + "/TV/Show/Show - S01E01/Show - S01E01 - Episode Title - 720p.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Single(result); + Assert.Single(result[0].AlternateVersions); + Assert.Contains("1080p", result[0].Files[0].Path, StringComparison.Ordinal); + Assert.Contains("720p", result[0].AlternateVersions[0].Files[0].Path, StringComparison.Ordinal); + } + + [Fact] + public void TestMultiVersionEpisodeWithTitleMixedFolder() + { + // Multiple different episodes with titles and resolution variants in a season folder + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01 - Pilot - 1080p.mkv", + "/TV/Show/Season 1/Show - S01E01 - Pilot - 720p.mkv", + "/TV/Show/Season 1/Show - S01E02 - Second Episode - 1080p.mkv", + "/TV/Show/Season 1/Show - S01E02 - Second Episode - 720p.mkv", + "/TV/Show/Season 1/Show - S01E03 - Third Episode.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Equal(3, result.Count); + + var e01 = result.FirstOrDefault(r => r.Files[0].Path.Contains("S01E01", StringComparison.Ordinal)); + Assert.NotNull(e01); + Assert.Single(e01!.AlternateVersions); + + var e02 = result.FirstOrDefault(r => r.Files[0].Path.Contains("S01E02", StringComparison.Ordinal)); + Assert.NotNull(e02); + Assert.Single(e02!.AlternateVersions); + + var e03 = result.FirstOrDefault(r => r.Files[0].Path.Contains("S01E03", StringComparison.Ordinal)); + Assert.NotNull(e03); + Assert.Empty(e03!.AlternateVersions); + } + + [Fact] + public void TestMultiVersionEpisodeInSeasonSubfolder() + { + // Two versions of S01E01 in their own subfolder under a season folder + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01/Show - S01E01 - 1080p.mkv", + "/TV/Show/Season 1/Show - S01E01/Show - S01E01 - 720p.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Single(result); + Assert.Single(result[0].AlternateVersions); + Assert.Contains("1080p", result[0].Files[0].Path, StringComparison.Ordinal); + Assert.Contains("720p", result[0].AlternateVersions[0].Files[0].Path, StringComparison.Ordinal); + } + + [Fact] + public void TestMultiVersionEpisodeWithTitleAndVersionSuffix() + { + // Episodes with episode title AND a named version suffix + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01 - Pilot - Aired.mkv", + "/TV/Show/Season 1/Show - S01E01 - Pilot - Uncensored.mkv", + "/TV/Show/Season 1/Show - S01E02 - The Getaway - Aired.mkv", + "/TV/Show/Season 1/Show - S01E02 - The Getaway - Uncensored.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Equal(2, result.Count); + Assert.All(result, r => Assert.Single(r.AlternateVersions)); + } + + [Fact] + public void TestMultiVersionEpisodeWithAdditionalPartsCd() + { + // Stacked episode (cd1/cd2) with higher resolution alongside a single-file lower-res version + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01 - 1080p cd1.mkv", + "/TV/Show/Season 1/Show - S01E01 - 1080p cd2.mkv", + "/TV/Show/Season 1/Show - S01E01 - 720p.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Single(result); + Assert.Equal(2, result[0].Files.Count); + Assert.Single(result[0].AlternateVersions); + Assert.Contains("720p", result[0].AlternateVersions[0].Files[0].Path, StringComparison.Ordinal); + } + + [Fact] + public void TestMultiVersionEpisodeWithAdditionalPartsDashPart() + { + // Stacked episode using "- part1" / "- part2" separator + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01 - 1080p - part1.mkv", + "/TV/Show/Season 1/Show - S01E01 - 1080p - part2.mkv", + "/TV/Show/Season 1/Show - S01E01 - 720p.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Single(result); + Assert.Equal(2, result[0].Files.Count); + Assert.Single(result[0].AlternateVersions); + Assert.Contains("720p", result[0].AlternateVersions[0].Files[0].Path, StringComparison.Ordinal); + } + + [Fact] + public void TestMultiVersionEpisodeWithAdditionalPartsPt() + { + // Stacked episode using "pt1" / "pt2" short form + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01 - 1080p.pt1.mkv", + "/TV/Show/Season 1/Show - S01E01 - 1080p.pt2.mkv", + "/TV/Show/Season 1/Show - S01E01 - 720p.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Single(result); + Assert.Equal(2, result[0].Files.Count); + Assert.Single(result[0].AlternateVersions); + Assert.Contains("720p", result[0].AlternateVersions[0].Files[0].Path, StringComparison.Ordinal); + } + + [Fact] + public void TestMultiVersionEpisodeWithAdditionalPartsAndTitle() + { + // Stacked episode with episode title in filename + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01 - Pilot - 1080p part1.mkv", + "/TV/Show/Season 1/Show - S01E01 - Pilot - 1080p part2.mkv", + "/TV/Show/Season 1/Show - S01E01 - Pilot - 720p.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Single(result); + // Primary should be the stacked 1080p version with 2 files + Assert.Equal(2, result[0].Files.Count); + Assert.Single(result[0].AlternateVersions); + Assert.Contains("720p", result[0].AlternateVersions[0].Files[0].Path, StringComparison.Ordinal); + } + + [Fact] + public void TestMultiVersionEpisodeWithAdditionalPartsAndTitleDashSeparator() + { + // Stacked episode with episode title using "- part1" separator + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01 - Pilot - 1080p - part1.mkv", + "/TV/Show/Season 1/Show - S01E01 - Pilot - 1080p - part2.mkv", + "/TV/Show/Season 1/Show - S01E01 - Pilot - 720p.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Single(result); + // Primary should be the stacked 1080p version with 2 files + Assert.Equal(2, result[0].Files.Count); + Assert.Single(result[0].AlternateVersions); + Assert.Contains("720p", result[0].AlternateVersions[0].Files[0].Path, StringComparison.Ordinal); + } + + [Fact] + public void TestMultiVersionEpisodeWithAdditionalPartsAndMultipleEpisodes() + { + // Stacked episode alongside single-file version, plus a different episode + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01 - 1080p cd1.mkv", + "/TV/Show/Season 1/Show - S01E01 - 1080p cd2.mkv", + "/TV/Show/Season 1/Show - S01E01 - 720p.mkv", + "/TV/Show/Season 1/Show - S01E02 - Other.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Equal(2, result.Count); + + // S01E01: stacked (cd1+cd2) primary with 720p alternate + var e01 = result.FirstOrDefault(r => r.Files[0].Path.Contains("S01E01", StringComparison.Ordinal)); + Assert.NotNull(e01); + Assert.Equal(2, e01!.Files.Count); + Assert.Single(e01.AlternateVersions); + + // S01E02: standalone + var e02 = result.FirstOrDefault(r => r.Files[0].Path.Contains("S01E02", StringComparison.Ordinal)); + Assert.NotNull(e02); + Assert.Empty(e02!.AlternateVersions); + } + + [Fact] + public void TestMultiVersionEpisodePartStackAlongsideSingleFileResolutions() + { + // A part-stacked episode (3 parts, no resolution suffix) alongside single-file 720p and 1080p versions. + // The multi-part stack is preferred as primary. + var files = new[] + { + "/TV/Show/Season 1/S01E01 - 720p.mkv", + "/TV/Show/Season 1/S01E01 - 1080p.mkv", + "/TV/Show/Season 1/S01E01 - Part 1.mkv", + "/TV/Show/Season 1/S01E01 - Part 2.mkv", + "/TV/Show/Season 1/S01E01 - Part 3.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Single(result); + Assert.Equal(3, result[0].Files.Count); + Assert.All(result[0].Files, f => Assert.Contains("Part", f.Path, StringComparison.Ordinal)); + Assert.Equal(2, result[0].AlternateVersions.Count); + Assert.Contains(result[0].AlternateVersions, f => f.Files[0].Path.Contains("1080p", StringComparison.Ordinal)); + Assert.Contains(result[0].AlternateVersions, f => f.Files[0].Path.Contains("720p", StringComparison.Ordinal)); + } + + [Fact] + public void TestMultiVersionEpisodeTwoPartStacks() + { + // Two part-suffixed stacks of the same episode at different resolutions. + // The 1080p stack is primary, the 720p stack is preserved as a multi-file alternate. + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01 - 1080p - part1.mkv", + "/TV/Show/Season 1/Show - S01E01 - 1080p - part2.mkv", + "/TV/Show/Season 1/Show - S01E01 - 720p - part1.mkv", + "/TV/Show/Season 1/Show - S01E01 - 720p - part2.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Single(result); + Assert.Equal(2, result[0].Files.Count); + Assert.Contains("1080p", result[0].Files[0].Path, StringComparison.Ordinal); + + Assert.Single(result[0].AlternateVersions); + var alt = result[0].AlternateVersions[0]; + Assert.Equal(2, alt.Files.Count); + Assert.All(alt.Files, f => Assert.Contains("720p", f.Path, StringComparison.Ordinal)); + } + + [Fact] + public void TestMultiVersionEpisodePartStackWithTrailer() + { + // A part-stacked multi-version episode alongside a trailer must not pull the trailer into the version group + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01 - 1080p part1.mkv", + "/TV/Show/Season 1/Show - S01E01 - 1080p part2.mkv", + "/TV/Show/Season 1/Show - S01E01 - 720p.mkv", + "/TV/Show/Season 1/Show - S01E01-trailer.mp4" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Equal(2, result.Count); + + var episode = result.FirstOrDefault(r => r.ExtraType is null); + Assert.NotNull(episode); + Assert.Equal(2, episode!.Files.Count); + Assert.Single(episode.AlternateVersions); + Assert.Contains("720p", episode.AlternateVersions[0].Files[0].Path, StringComparison.Ordinal); + + var trailer = result.FirstOrDefault(r => r.ExtraType is not null); + Assert.NotNull(trailer); + Assert.Equal(ExtraType.Trailer, trailer!.ExtraType); + } + + [Fact] + public void TestMovieStackingWithPartNaming() + { + // Movie stacking with "part1"/"part2" naming + var files = new[] + { + "/movies/Movie/Movie part1.mkv", + "/movies/Movie/Movie part2.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); + + Assert.Single(result); + Assert.Equal(2, result[0].Files.Count); + } + + [Fact] + public void TestMovieStackingWithDashPartNaming() + { + // Movie stacking with "- part1" / "- part2" dash separator + var files = new[] + { + "/movies/Movie/Movie - part1.mkv", + "/movies/Movie/Movie - part2.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); + + Assert.Single(result); + Assert.Equal(2, result[0].Files.Count); + } + + [Fact] + public void TestMovieStackingWithPtNaming() + { + // Movie stacking with "pt1"/"pt2" short form + var files = new[] + { + "/movies/Movie/Movie.pt1.mkv", + "/movies/Movie/Movie.pt2.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); + + Assert.Single(result); + Assert.Equal(2, result[0].Files.Count); + } + + [Fact] + public void TestMovieStackingWithHyphenNoSpaces() + { + // Movie stacking with hyphen directly adjacent to "part" (no spaces) + var files = new[] + { + "/movies/Movie/Movie-part1.mkv", + "/movies/Movie/Movie-part2.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); + + Assert.Single(result); + Assert.Equal(2, result[0].Files.Count); + } + + [Fact] + public void TestMovieStackingWithHyphenNoSpacesAndVersion() + { + // Movie stacking with hyphen-no-space separators plus a version alternate + var files = new[] + { + "/movies/Movie/Movie-1080p-part1.mkv", + "/movies/Movie/Movie-1080p-part2.mkv", + "/movies/Movie/Movie-720p.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); + + Assert.Single(result); + // Stacked 1080p (2 files) should be primary, 720p is alternate + Assert.Equal(2, result[0].Files.Count); + Assert.Single(result[0].AlternateVersions); + } + + [Fact] + public void TestMovieMultiVersionWithStackedAlternate() + { + // Movie folder where the folder-named file is the primary (single file via primaryOverride) + // and an alternate version is itself a stack. The stacked alternate must keep all its files. + var files = new[] + { + "/movies/Inception (2010)/Inception (2010).mkv", + "/movies/Inception (2010)/Inception (2010) - 4k part1.mkv", + "/movies/Inception (2010)/Inception (2010) - 4k part2.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); + + Assert.Single(result); + Assert.Single(result[0].Files); + Assert.Equal("/movies/Inception (2010)/Inception (2010).mkv", result[0].Files[0].Path); + + Assert.Single(result[0].AlternateVersions); + var stackedAlternate = result[0].AlternateVersions[0]; + Assert.Equal(2, stackedAlternate.Files.Count); + Assert.All(stackedAlternate.Files, f => Assert.Contains("4k part", f.Path, StringComparison.Ordinal)); + } + + [Fact] + public void TestEpisodeStackingWithHyphenNoSpaces() + { + // Episode stacking with hyphen-no-space separators plus version alternate + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01-1080p-cd1.mkv", + "/TV/Show/Season 1/Show - S01E01-1080p-cd2.mkv", + "/TV/Show/Season 1/Show - S01E01-720p.mkv" + }; + + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), + collectionType: CollectionType.tvshows).ToList(); + + Assert.Single(result); + // Stacked 1080p (2 files) should be primary, 720p is alternate + Assert.Equal(2, result[0].Files.Count); + Assert.Single(result[0].AlternateVersions); + } + + [Fact] + public void TestEpisodeStackingWithHyphenNoSpacesAndTitle() + { + // Episode stacking with title and hyphen-no-space separators + var files = new[] + { + "/TV/Show/Season 1/Show - S01E01 - Pilot-1080p-part1.mkv", + "/TV/Show/Season 1/Show - S01E01 - Pilot-1080p-part2.mkv", + "/TV/Show/Season 1/Show - S01E01 - Pilot-720p.mkv" + }; + + var result = _videoListResolver.Resolve( files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + collectionType: CollectionType.tvshows).ToList(); Assert.Single(result); + // Stacked 1080p (2 files) should be primary, 720p is alternate + Assert.Equal(2, result[0].Files.Count); Assert.Single(result[0].AlternateVersions); } } diff --git a/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs b/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs index d3164ba9c9..53f16b92d6 100644 --- a/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs @@ -10,6 +10,12 @@ namespace Jellyfin.Naming.Tests.Video public class VideoListResolverTests { private readonly NamingOptions _namingOptions = new NamingOptions(); + private readonly VideoListResolver _videoListResolver; + + public VideoListResolverTests() + { + _videoListResolver = new VideoListResolver(_namingOptions); + } [Fact] public void TestStackAndExtras() @@ -40,9 +46,8 @@ namespace Jellyfin.Naming.Tests.Video "WillyWonka-trailer.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(11, result.Count); var batman = result.FirstOrDefault(x => string.Equals(x.Name, "Batman", StringComparison.Ordinal)); @@ -74,9 +79,8 @@ namespace Jellyfin.Naming.Tests.Video "300.nfo" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result); } @@ -90,9 +94,8 @@ namespace Jellyfin.Naming.Tests.Video "300 - trailer.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(2, result.Count); Assert.False(result[0].ExtraType.HasValue); @@ -108,9 +111,8 @@ namespace Jellyfin.Naming.Tests.Video "X-Men Days of Future Past-trailer.mp4" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(2, result.Count); Assert.False(result[0].ExtraType.HasValue); @@ -127,9 +129,8 @@ namespace Jellyfin.Naming.Tests.Video "X-Men Days of Future Past-trailer2.mp4" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(3, result.Count); Assert.False(result[0].ExtraType.HasValue); @@ -147,9 +148,8 @@ namespace Jellyfin.Naming.Tests.Video "Looper.2012.bluray.720p.x264.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(3, result.Count); Assert.False(result[0].ExtraType.HasValue); @@ -166,9 +166,8 @@ namespace Jellyfin.Naming.Tests.Video "/movies/Looper (2012)/Looper.bluray.720p.x264.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(2, result.Count); Assert.False(result[0].ExtraType.HasValue); @@ -188,9 +187,8 @@ namespace Jellyfin.Naming.Tests.Video "My video 5.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(5, result.Count); } @@ -204,9 +202,8 @@ namespace Jellyfin.Naming.Tests.Video "M:/Movies (DVD)/Movies (Musical)/Sound of Music (1965)/Sound of Music Disc 2" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, true, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, true, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result); } @@ -221,9 +218,8 @@ namespace Jellyfin.Naming.Tests.Video "My movie #2.mp4" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, true, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, true, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(2, result.Count); } @@ -239,9 +235,8 @@ namespace Jellyfin.Naming.Tests.Video "No (2012)-trailer.mp4" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(3, result.Count); Assert.False(result[0].ExtraType.HasValue); @@ -260,9 +255,8 @@ namespace Jellyfin.Naming.Tests.Video "/Movies/trailer.mp4" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(4, result.Count); Assert.False(result[0].ExtraType.HasValue); @@ -282,9 +276,8 @@ namespace Jellyfin.Naming.Tests.Video "/MCFAMILY-PC/Private3$/Heterosexual/Breast In Class 2 Counterfeit Racks (2011)/Breast In Class 2 Disc 2 cd2.avi" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(2, result.Count); } @@ -297,9 +290,8 @@ namespace Jellyfin.Naming.Tests.Video "/nas-markrobbo78/Videos/INDEX HTPC/Movies/Watched/3 - ACTION/Argo (2012)/movie.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result); } @@ -312,9 +304,8 @@ namespace Jellyfin.Naming.Tests.Video "The Colony.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Single(result); } @@ -328,9 +319,8 @@ namespace Jellyfin.Naming.Tests.Video "Four Sisters and a Wedding - B.avi" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); // The result should contain two individual movies // Version grouping should not work here, because the files are not in a directory with the name 'Four Sisters and a Wedding' @@ -346,9 +336,8 @@ namespace Jellyfin.Naming.Tests.Video "Four Rooms - A.mp4" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(2, result.Count); } @@ -362,9 +351,8 @@ namespace Jellyfin.Naming.Tests.Video "/Server/Despicable Me/trailer.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(2, result.Count); Assert.False(result[0].ExtraType.HasValue); @@ -380,9 +368,8 @@ namespace Jellyfin.Naming.Tests.Video "/Server/Despicable Me/trailers/some title.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(2, result.Count); Assert.False(result[0].ExtraType.HasValue); @@ -398,9 +385,8 @@ namespace Jellyfin.Naming.Tests.Video "/Movies/Despicable Me/trailers/trailer.mkv" }; - var result = VideoListResolver.Resolve( - files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(), - _namingOptions).ToList(); + var result = _videoListResolver.Resolve( + files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList()).ToList(); Assert.Equal(2, result.Count); Assert.False(result[0].ExtraType.HasValue); diff --git a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs index 87e7a4b564..5749944fcd 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs @@ -576,7 +576,8 @@ namespace Jellyfin.Providers.Tests.Manager baseItemManager!, Mock.Of<ILyricManager>(), Mock.Of<IMemoryCache>(), - Mock.Of<IMediaSegmentManager>()); + Mock.Of<IMediaSegmentManager>(), + Mock.Of<ISimilarItemsManager>()); return providerManager; } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs index aed584355c..e1346a8436 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs @@ -1,7 +1,13 @@ +using System.Collections.Generic; using Emby.Naming.Common; +using Emby.Naming.Video; using Emby.Server.Implementations.Library.Resolvers.Movies; +using Jellyfin.Data.Enums; using MediaBrowser.Controller; using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; @@ -14,11 +20,12 @@ namespace Jellyfin.Server.Implementations.Tests.Library; public class MovieResolverTests { private static readonly NamingOptions _namingOptions = new(); + private static readonly VideoListResolver _videoListResolver = new(_namingOptions); [Fact] public void Resolve_GivenLocalAlternateVersion_ResolvesToVideo() { - var movieResolver = new MovieResolver(Mock.Of<IImageProcessor>(), Mock.Of<ILogger<MovieResolver>>(), _namingOptions, Mock.Of<IDirectoryService>()); + var movieResolver = new MovieResolver(Mock.Of<IImageProcessor>(), Mock.Of<ILogger<MovieResolver>>(), _namingOptions, Mock.Of<IDirectoryService>(), _videoListResolver); var itemResolveArgs = new ItemResolveArgs( Mock.Of<IServerApplicationPaths>(), null) @@ -32,4 +39,54 @@ public class MovieResolverTests Assert.NotNull(movieResolver.Resolve(itemResolveArgs)); } + + [Fact] + public void ResolveMultiple_GivenTvShowsCollection_CreatesEpisodeItems() + { + // For a tvshows collection, the multi-version grouping must still produce + // Episode BaseItems (not generic Video) so downstream metadata fetching + // and series-aware logic apply. + var movieResolver = new MovieResolver(Mock.Of<IImageProcessor>(), Mock.Of<ILogger<MovieResolver>>(), _namingOptions, Mock.Of<IDirectoryService>(), _videoListResolver); + + var parent = new Folder { Path = "/TV/Show/Season 1" }; + var files = new List<FileSystemMetadata> + { + new() { FullName = "/TV/Show/Season 1/Show - S01E01 - 1080p.mkv", Name = "Show - S01E01 - 1080p.mkv", IsDirectory = false }, + new() { FullName = "/TV/Show/Season 1/Show - S01E01 - 720p.mkv", Name = "Show - S01E01 - 720p.mkv", IsDirectory = false }, + new() { FullName = "/TV/Show/Season 1/Show - S01E02.mkv", Name = "Show - S01E02.mkv", IsDirectory = false } + }; + + var result = movieResolver.ResolveMultiple(parent, files, CollectionType.tvshows, Mock.Of<IDirectoryService>()); + + Assert.NotNull(result); + Assert.Equal(2, result.Items.Count); + Assert.All(result.Items, item => Assert.IsType<Episode>(item)); + + // The S01E01 item should have one alternate version + var s01e01 = result.Items.Find(i => i.Path.Contains("S01E01", System.StringComparison.Ordinal)); + Assert.NotNull(s01e01); + Assert.Single(((Video)s01e01).LocalAlternateVersions); + } + + [Fact] + public void ResolveMultiple_GivenMoviesCollection_CreatesMovieItems() + { + // For a movies collection, the multi-version grouping must produce Movie + // BaseItems (not generic Video) so downstream movie-specific logic applies. + var movieResolver = new MovieResolver(Mock.Of<IImageProcessor>(), Mock.Of<ILogger<MovieResolver>>(), _namingOptions, Mock.Of<IDirectoryService>(), _videoListResolver); + + var parent = new Folder { Path = "/movies/Inception (2010)" }; + var files = new List<FileSystemMetadata> + { + new() { FullName = "/movies/Inception (2010)/Inception (2010) - 1080p.mkv", Name = "Inception (2010) - 1080p.mkv", IsDirectory = false }, + new() { FullName = "/movies/Inception (2010)/Inception (2010) - 720p.mkv", Name = "Inception (2010) - 720p.mkv", IsDirectory = false } + }; + + var result = movieResolver.ResolveMultiple(parent, files, CollectionType.movies, Mock.Of<IDirectoryService>()); + + Assert.NotNull(result); + Assert.Single(result.Items); + Assert.All(result.Items, item => Assert.IsType<Movie>(item)); + Assert.Single(((Video)result.Items[0]).LocalAlternateVersions); + } } |
