diff options
| author | Bond-009 <bond.009@outlook.com> | 2026-05-15 10:00:53 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-05-15 10:00:53 +0200 |
| commit | 8e602f982f79201cc316f64baf018a5a536d38f6 (patch) | |
| tree | 94af198766ed00d736b31918b709e366f226845b | |
| parent | 097097300a473c7abe9d578169f60c39174345f1 (diff) | |
| parent | 0b209fe66b33bfeed1b1259efba31ceb643c4e0e (diff) | |
Merge pull request #15970 from Shadowghost/similarity
Implement similarity providers
40 files changed, 2087 insertions, 61 deletions
diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 3e98a5276c..b624a6d4f9 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -25,6 +25,7 @@ using Emby.Server.Implementations.Dto; using Emby.Server.Implementations.HttpServer.Security; using Emby.Server.Implementations.IO; using Emby.Server.Implementations.Library; +using Emby.Server.Implementations.Library.SimilarItems; using Emby.Server.Implementations.Localization; using Emby.Server.Implementations.Playlists; using Emby.Server.Implementations.Plugins; @@ -92,7 +93,11 @@ using MediaBrowser.Model.System; using MediaBrowser.Model.Tasks; using MediaBrowser.Providers.Lyric; using MediaBrowser.Providers.Manager; +using MediaBrowser.Providers.Plugins.ListenBrainz; +using MediaBrowser.Providers.Plugins.ListenBrainz.Api; using MediaBrowser.Providers.Plugins.Tmdb; +using MediaBrowser.Providers.Plugins.Tmdb.Movies; +using MediaBrowser.Providers.Plugins.Tmdb.TV; using MediaBrowser.Providers.Subtitles; using MediaBrowser.XbmcMetadata.Providers; using Microsoft.AspNetCore.Http; @@ -483,6 +488,11 @@ namespace Emby.Server.Implementations serviceCollection.AddScoped<ISystemManager, SystemManager>(); serviceCollection.AddSingleton<TmdbClientManager>(); + serviceCollection.AddSingleton<TmdbMovieSimilarProvider>(); + serviceCollection.AddSingleton<TmdbSeriesSimilarProvider>(); + + serviceCollection.AddSingleton<ListenBrainzLabsClient>(); + serviceCollection.AddSingleton<ListenBrainzSimilarArtistProvider>(); serviceCollection.AddSingleton(NetManager); @@ -536,6 +546,8 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton<ILibraryMonitor, LibraryMonitor>(); serviceCollection.AddSingleton<DotIgnoreIgnoreRule>(); + serviceCollection.AddSingleton<ISimilarItemsManager, SimilarItemsManager>(); + serviceCollection.AddSingleton<ISearchEngine, SearchEngine>(); serviceCollection.AddSingleton<IWebSocketManager, WebSocketManager>(); @@ -693,6 +705,8 @@ namespace Emby.Server.Implementations GetExports<IExternalUrlProvider>()); Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>()); + + Resolve<ISimilarItemsManager>().AddParts(GetExports<ISimilarItemsProvider>()); } /// <summary> diff --git a/Emby.Server.Implementations/Library/SimilarItems/AudioSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/AudioSimilarItemsProvider.cs new file mode 100644 index 0000000000..1cc670b8ee --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/AudioSimilarItemsProvider.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Provides similar items for audio tracks. +/// </summary> +public class AudioSimilarItemsProvider : ILocalSimilarItemsProvider<Audio> +{ + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="AudioSimilarItemsProvider"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + public AudioSimilarItemsProvider(ILibraryManager libraryManager) + { + _libraryManager = libraryManager; + } + + /// <inheritdoc/> + public string Name => "Local Genre/Tag"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider; + + /// <inheritdoc/> + public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(Audio item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + var internalQuery = new InternalItemsQuery(query.User) + { + Genres = item.Genres, + Tags = item.Tags, + Limit = query.Limit, + DtoOptions = query.DtoOptions ?? new DtoOptions(), + ExcludeItemIds = [.. query.ExcludeItemIds], + ExcludeArtistIds = [.. query.ExcludeArtistIds], + IncludeItemTypes = [BaseItemKind.Audio], + EnableGroupByMetadataKey = false, + EnableTotalRecordCount = true, + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] + }; + + return Task.FromResult(_libraryManager.GetItemList(internalQuery)); + } +} diff --git a/Emby.Server.Implementations/Library/SimilarItems/LiveTvProgramSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/LiveTvProgramSimilarItemsProvider.cs new file mode 100644 index 0000000000..7665ee2f79 --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/LiveTvProgramSimilarItemsProvider.cs @@ -0,0 +1,94 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Configuration; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Provides similar items for Live TV programs. +/// </summary> +public class LiveTvProgramSimilarItemsProvider : ILocalSimilarItemsProvider<LiveTvProgram> +{ + private readonly ILibraryManager _libraryManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="LiveTvProgramSimilarItemsProvider"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + /// <param name="serverConfigurationManager">The server configuration manager.</param> + public LiveTvProgramSimilarItemsProvider( + ILibraryManager libraryManager, + IServerConfigurationManager serverConfigurationManager) + { + _libraryManager = libraryManager; + _serverConfigurationManager = serverConfigurationManager; + } + + /// <inheritdoc/> + public string Name => "Local Genre/Tag"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider; + + /// <inheritdoc/> + public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(LiveTvProgram item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + BaseItemKind[] includeItemTypes; + bool enableGroupByMetadataKey; + bool enableTotalRecordCount; + + if (item.IsMovie) + { + // Movie-like program + var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; + + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + { + itemTypes.Add(BaseItemKind.Trailer); + itemTypes.Add(BaseItemKind.LiveTvProgram); + } + + includeItemTypes = [.. itemTypes]; + enableGroupByMetadataKey = true; + enableTotalRecordCount = false; + } + else if (item.IsSeries) + { + // Series-like program + includeItemTypes = [BaseItemKind.Series]; + enableGroupByMetadataKey = false; + enableTotalRecordCount = true; + } + else + { + // Default - match same type + includeItemTypes = [item.GetBaseItemKind()]; + enableGroupByMetadataKey = false; + enableTotalRecordCount = true; + } + + var internalQuery = new InternalItemsQuery(query.User) + { + Genres = item.Genres, + Tags = item.Tags, + Limit = query.Limit, + DtoOptions = query.DtoOptions ?? new DtoOptions(), + ExcludeItemIds = [.. query.ExcludeItemIds], + IncludeItemTypes = includeItemTypes, + EnableGroupByMetadataKey = enableGroupByMetadataKey, + EnableTotalRecordCount = enableTotalRecordCount, + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] + }; + + return Task.FromResult(_libraryManager.GetItemList(internalQuery)); + } +} diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs new file mode 100644 index 0000000000..93aa0574c0 --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Provides similar items for movies and trailers. +/// </summary> +public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie>, ILocalSimilarItemsProvider<Trailer> +{ + private readonly ILibraryManager _libraryManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="MovieSimilarItemsProvider"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + /// <param name="serverConfigurationManager">The server configuration manager.</param> + public MovieSimilarItemsProvider( + ILibraryManager libraryManager, + IServerConfigurationManager serverConfigurationManager) + { + _libraryManager = libraryManager; + _serverConfigurationManager = serverConfigurationManager; + } + + /// <inheritdoc/> + public string Name => "Local Genre/Tag"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider; + + /// <inheritdoc/> + public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(Movie item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + return Task.FromResult(GetSimilarMovieItems(item, query)); + } + + /// <inheritdoc/> + public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(Trailer item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + return Task.FromResult(GetSimilarMovieItems(item, query)); + } + + bool ILocalSimilarItemsProvider.Supports(Type itemType) + => typeof(Movie).IsAssignableFrom(itemType) || typeof(Trailer).IsAssignableFrom(itemType); + + Task<IReadOnlyList<BaseItem>> ILocalSimilarItemsProvider.GetSimilarItemsAsync(BaseItem item, SimilarItemsQuery query, CancellationToken cancellationToken) + => item switch + { + Movie movie => GetSimilarItemsAsync(movie, query, cancellationToken), + Trailer trailer => GetSimilarItemsAsync(trailer, query, cancellationToken), + _ => throw new ArgumentException($"Unsupported item type {item.GetType()}", nameof(item)) + }; + + private IReadOnlyList<BaseItem> GetSimilarMovieItems(BaseItem item, SimilarItemsQuery query) + { + var includeItemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; + + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + { + includeItemTypes.Add(BaseItemKind.Trailer); + includeItemTypes.Add(BaseItemKind.LiveTvProgram); + } + + var internalQuery = new InternalItemsQuery(query.User) + { + Genres = item.Genres, + Tags = item.Tags, + Limit = query.Limit, + DtoOptions = query.DtoOptions ?? new DtoOptions(), + ExcludeItemIds = [.. query.ExcludeItemIds], + IncludeItemTypes = [.. includeItemTypes], + EnableGroupByMetadataKey = true, + EnableTotalRecordCount = false, + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] + }; + + return _libraryManager.GetItemList(internalQuery); + } +} diff --git a/Emby.Server.Implementations/Library/SimilarItems/MusicAlbumSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MusicAlbumSimilarItemsProvider.cs new file mode 100644 index 0000000000..c13045deda --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/MusicAlbumSimilarItemsProvider.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Provides similar items for music albums. +/// </summary> +public class MusicAlbumSimilarItemsProvider : ILocalSimilarItemsProvider<MusicAlbum> +{ + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="MusicAlbumSimilarItemsProvider"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + public MusicAlbumSimilarItemsProvider(ILibraryManager libraryManager) + { + _libraryManager = libraryManager; + } + + /// <inheritdoc/> + public string Name => "Local Genre/Tag"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider; + + /// <inheritdoc/> + public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(MusicAlbum item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + var internalQuery = new InternalItemsQuery(query.User) + { + Genres = item.Genres, + Tags = item.Tags, + Limit = query.Limit, + DtoOptions = query.DtoOptions ?? new DtoOptions(), + ExcludeItemIds = [.. query.ExcludeItemIds], + ExcludeArtistIds = [.. query.ExcludeArtistIds], + IncludeItemTypes = [BaseItemKind.MusicAlbum], + EnableGroupByMetadataKey = false, + EnableTotalRecordCount = true, + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] + }; + + return Task.FromResult(_libraryManager.GetItemList(internalQuery)); + } +} diff --git a/Emby.Server.Implementations/Library/SimilarItems/MusicArtistSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MusicArtistSimilarItemsProvider.cs new file mode 100644 index 0000000000..3331419442 --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/MusicArtistSimilarItemsProvider.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Provides similar items for music artists. +/// </summary> +public class MusicArtistSimilarItemsProvider : ILocalSimilarItemsProvider<MusicArtist> +{ + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="MusicArtistSimilarItemsProvider"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + public MusicArtistSimilarItemsProvider(ILibraryManager libraryManager) + { + _libraryManager = libraryManager; + } + + /// <inheritdoc/> + public string Name => "Local Genre/Tag"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider; + + /// <inheritdoc/> + public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(MusicArtist item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + var internalQuery = new InternalItemsQuery(query.User) + { + Genres = item.Genres, + Tags = item.Tags, + Limit = query.Limit, + DtoOptions = query.DtoOptions ?? new DtoOptions(), + ExcludeItemIds = [.. query.ExcludeItemIds], + ExcludeArtistIds = [.. query.ExcludeArtistIds], + IncludeItemTypes = [BaseItemKind.MusicArtist], + EnableGroupByMetadataKey = false, + EnableTotalRecordCount = true, + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] + }; + + return Task.FromResult(_libraryManager.GetItemList(internalQuery)); + } +} diff --git a/Emby.Server.Implementations/Library/SimilarItems/SeriesSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/SeriesSimilarItemsProvider.cs new file mode 100644 index 0000000000..0366fb752e --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/SeriesSimilarItemsProvider.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Provides similar items for TV series. +/// </summary> +public class SeriesSimilarItemsProvider : ILocalSimilarItemsProvider<Series> +{ + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="SeriesSimilarItemsProvider"/> class. + /// </summary> + /// <param name="libraryManager">The library manager.</param> + public SeriesSimilarItemsProvider(ILibraryManager libraryManager) + { + _libraryManager = libraryManager; + } + + /// <inheritdoc/> + public string Name => "Local Genre/Tag"; + + /// <inheritdoc/> + public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider; + + /// <inheritdoc/> + public Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(Series item, SimilarItemsQuery query, CancellationToken cancellationToken) + { + var internalQuery = new InternalItemsQuery(query.User) + { + Genres = item.Genres, + Tags = item.Tags, + Limit = query.Limit, + DtoOptions = query.DtoOptions ?? new DtoOptions(), + ExcludeItemIds = [.. query.ExcludeItemIds], + IncludeItemTypes = [BaseItemKind.Series], + EnableGroupByMetadataKey = false, + EnableTotalRecordCount = true, + OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)] + }; + + return Task.FromResult(_libraryManager.GetItemList(internalQuery)); + } +} diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs new file mode 100644 index 0000000000..b56779cf3f --- /dev/null +++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs @@ -0,0 +1,406 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions.Json; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Querying; +using Microsoft.Extensions.Logging; + +namespace Emby.Server.Implementations.Library.SimilarItems; + +/// <summary> +/// Manages similar items providers and orchestrates similar items operations. +/// </summary> +public class SimilarItemsManager : ISimilarItemsManager +{ + private readonly ILogger<SimilarItemsManager> _logger; + private readonly IServerApplicationPaths _appPaths; + private readonly ILibraryManager _libraryManager; + private readonly IFileSystem _fileSystem; + private ISimilarItemsProvider[] _similarItemsProviders = []; + + /// <summary> + /// Initializes a new instance of the <see cref="SimilarItemsManager"/> class. + /// </summary> + /// <param name="logger">The logger.</param> + /// <param name="appPaths">The server application paths.</param> + /// <param name="libraryManager">The library manager.</param> + /// <param name="fileSystem">The file system.</param> + public SimilarItemsManager( + ILogger<SimilarItemsManager> logger, + IServerApplicationPaths appPaths, + ILibraryManager libraryManager, + IFileSystem fileSystem) + { + _logger = logger; + _appPaths = appPaths; + _libraryManager = libraryManager; + _fileSystem = fileSystem; + } + + /// <inheritdoc/> + public void AddParts(IEnumerable<ISimilarItemsProvider> providers) + { + _similarItemsProviders = providers.ToArray(); + } + + /// <inheritdoc/> + public IReadOnlyList<ISimilarItemsProvider> GetSimilarItemsProviders<T>() + where T : BaseItem + { + var itemType = typeof(T); + return _similarItemsProviders + .Where(p => (p is ILocalSimilarItemsProvider local && local.Supports(itemType)) + || (p is IRemoteSimilarItemsProvider remote && remote.Supports(itemType))) + .ToList(); + } + + /// <inheritdoc/> + public async Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync( + BaseItem item, + IReadOnlyList<Guid> excludeArtistIds, + User? user, + DtoOptions dtoOptions, + int? limit, + LibraryOptions? libraryOptions, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(item); + ArgumentNullException.ThrowIfNull(excludeArtistIds); + + var itemType = item.GetType(); + var requestedLimit = limit ?? 50; + var itemKind = item.GetBaseItemKind(); + + // Ensure ProviderIds is included in DtoOptions for matching remote provider responses + if (!dtoOptions.Fields.Contains(ItemFields.ProviderIds)) + { + dtoOptions.Fields = dtoOptions.Fields.Concat([ItemFields.ProviderIds]).ToArray(); + } + + // Local providers are always enabled. Remote providers must be explicitly enabled. + var localProviders = _similarItemsProviders + .OfType<ILocalSimilarItemsProvider>() + .Where(p => p.Supports(itemType)) + .ToList(); + var remoteProviders = _similarItemsProviders + .OfType<IRemoteSimilarItemsProvider>() + .Where(p => p.Supports(itemType)); + var matchingProviders = new List<ISimilarItemsProvider>(localProviders); + + var typeOptions = libraryOptions?.GetTypeOptions(itemType.Name); + if (typeOptions?.SimilarItemProviders?.Length > 0) + { + matchingProviders.AddRange(remoteProviders + .Where(p => typeOptions.SimilarItemProviders.Contains(p.Name, StringComparer.OrdinalIgnoreCase))); + } + + var orderConfig = typeOptions?.SimilarItemProviderOrder is { Length: > 0 } order + ? order + : typeOptions?.SimilarItemProviders; + var orderedProviders = matchingProviders + .OrderBy(p => GetConfiguredSimilarProviderOrder(orderConfig, p.Name)) + .ToList(); + + var allResults = new List<(BaseItem Item, float Score)>(); + var excludeIds = new HashSet<Guid> { item.Id }; + foreach (var (providerOrder, provider) in orderedProviders.Index()) + { + if (allResults.Count >= requestedLimit || cancellationToken.IsCancellationRequested) + { + break; + } + + try + { + if (provider is ILocalSimilarItemsProvider localProvider) + { + var query = new SimilarItemsQuery + { + User = user, + Limit = requestedLimit - allResults.Count, + DtoOptions = dtoOptions, + ExcludeItemIds = [.. excludeIds], + ExcludeArtistIds = excludeArtistIds + }; + + var items = await localProvider.GetSimilarItemsAsync(item, query, cancellationToken).ConfigureAwait(false); + + foreach (var (position, resultItem) in items.Index()) + { + if (excludeIds.Add(resultItem.Id)) + { + var score = CalculateScore(null, providerOrder, position); + allResults.Add((resultItem, score)); + } + } + } + else if (provider is IRemoteSimilarItemsProvider remoteProvider) + { + var cachePath = GetSimilarItemsCachePath(provider.Name, itemType.Name, item.Id); + + var cachedReferences = await TryReadSimilarItemsCacheAsync(cachePath, cancellationToken).ConfigureAwait(false); + if (cachedReferences is not null) + { + var resolvedItems = ResolveRemoteReferences(cachedReferences, providerOrder, user, dtoOptions, itemKind, excludeIds); + allResults.AddRange(resolvedItems); + continue; + } + + var query = new SimilarItemsQuery + { + User = user, + Limit = requestedLimit - allResults.Count, + DtoOptions = dtoOptions, + ExcludeItemIds = [.. excludeIds], + ExcludeArtistIds = excludeArtistIds + }; + + // Collect references in batches and resolve against local library. + // Stop fetching once we have enough resolved local items. + const int BatchSize = 20; + var remaining = requestedLimit - allResults.Count; + var collectedReferences = new List<SimilarItemReference>(); + var pendingBatch = new List<SimilarItemReference>(); + + await foreach (var reference in remoteProvider.GetSimilarItemsAsync(item, query, cancellationToken).ConfigureAwait(false)) + { + collectedReferences.Add(reference); + pendingBatch.Add(reference); + + if (pendingBatch.Count >= BatchSize) + { + var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds); + allResults.AddRange(resolvedItems); + remaining -= resolvedItems.Count; + pendingBatch.Clear(); + + if (remaining <= 0) + { + break; + } + } + } + + // Resolve any remaining references in the last partial batch + if (pendingBatch.Count > 0) + { + var resolvedItems = ResolveRemoteReferences(pendingBatch, providerOrder, user, dtoOptions, itemKind, excludeIds); + allResults.AddRange(resolvedItems); + } + + if (collectedReferences.Count > 0 && provider.CacheDuration is not null) + { + await SaveSimilarItemsCacheAsync(cachePath, collectedReferences, provider.CacheDuration.Value, cancellationToken).ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Similar items provider {ProviderName} failed for item {ItemId}", provider.Name, item.Id); + } + } + + return allResults + .OrderByDescending(x => x.Score) + .Select(x => x.Item) + .Take(requestedLimit) + .ToList(); + } + + private List<(BaseItem Item, float Score)> ResolveRemoteReferences( + IReadOnlyList<SimilarItemReference> references, + int providerOrder, + User? user, + DtoOptions dtoOptions, + BaseItemKind itemKind, + HashSet<Guid> excludeIds) + { + if (references.Count == 0) + { + return []; + } + + var resolvedById = new Dictionary<Guid, (BaseItem Item, float Score)>(); + var providerLookup = new Dictionary<(string ProviderName, string ProviderId), (float? Score, int Position)>(StringTupleComparer.Instance); + + foreach (var (position, match) in references.Index()) + { + var lookupKey = (match.ProviderName, match.ProviderId); + if (!providerLookup.TryGetValue(lookupKey, out var existing)) + { + providerLookup[lookupKey] = (match.Score, position); + } + else if (match.Score > existing.Score || (match.Score == existing.Score && position < existing.Position)) + { + providerLookup[lookupKey] = (match.Score, position); + } + } + + var allProviderIds = providerLookup + .GroupBy(kvp => kvp.Key.ProviderName) + .ToDictionary(g => g.Key, g => g.Select(x => x.Key.ProviderId).ToArray()); + + var query = new InternalItemsQuery(user) + { + HasAnyProviderIds = allProviderIds, + IncludeItemTypes = [itemKind], + DtoOptions = dtoOptions + }; + + var items = _libraryManager.GetItemList(query); + + foreach (var item in items) + { + if (excludeIds.Contains(item.Id) || resolvedById.ContainsKey(item.Id)) + { + continue; + } + + foreach (var providerName in allProviderIds.Keys) + { + if (item.TryGetProviderId(providerName, out var itemProviderId) && providerLookup.TryGetValue((providerName, itemProviderId), out var matchInfo)) + { + var score = CalculateScore(matchInfo.Score, providerOrder, matchInfo.Position); + if (!resolvedById.TryGetValue(item.Id, out var existing) || existing.Score < score) + { + excludeIds.Add(item.Id); + resolvedById[item.Id] = (item, score); + } + + break; + } + } + } + + return [.. resolvedById.Values]; + } + + private static float CalculateScore(float? matchScore, int providerOrder, int position) + { + // Use provider-supplied score if available, otherwise derive from position + var baseScore = matchScore ?? (1.0f - (position * 0.02f)); + + // Apply small boost based on provider order (higher priority providers get small bonus) + var priorityBoost = Math.Max(0, 10 - providerOrder) * 0.005f; + + return Math.Clamp(baseScore + priorityBoost, 0f, 1f); + } + + private static int GetConfiguredSimilarProviderOrder(string[]? orderConfig, string providerName) + { + if (orderConfig is null || orderConfig.Length == 0) + { + return int.MaxValue; + } + + var index = Array.FindIndex(orderConfig, name => string.Equals(name, providerName, StringComparison.OrdinalIgnoreCase)); + return index >= 0 ? index : int.MaxValue; + } + + private string GetSimilarItemsCachePath(string providerName, string baseItemType, Guid itemId) + { + var dataPath = Path.Combine( + _appPaths.CachePath, + $"{providerName.ToLowerInvariant()}-similar-{baseItemType.ToLowerInvariant()}"); + return Path.Combine(dataPath, $"{itemId.ToString("N", CultureInfo.InvariantCulture)}.json"); + } + + private async Task<List<SimilarItemReference>?> TryReadSimilarItemsCacheAsync(string cachePath, CancellationToken cancellationToken) + { + var fileInfo = _fileSystem.GetFileSystemInfo(cachePath); + if (!fileInfo.Exists || fileInfo.Length == 0) + { + return null; + } + + try + { + var stream = File.OpenRead(cachePath); + await using (stream.ConfigureAwait(false)) + { + var cache = await JsonSerializer.DeserializeAsync<SimilarItemsCache>(stream, JsonDefaults.Options, cancellationToken).ConfigureAwait(false); + if (cache?.References is not null && DateTime.UtcNow < cache.ExpiresAt) + { + return cache.References; + } + } + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Failed to read similar items cache from {CachePath}", cachePath); + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Failed to parse similar items cache from {CachePath}", cachePath); + } + + return null; + } + + private async Task SaveSimilarItemsCacheAsync(string cachePath, List<SimilarItemReference> references, TimeSpan cacheDuration, CancellationToken cancellationToken) + { + try + { + var directory = Path.GetDirectoryName(cachePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + var cache = new SimilarItemsCache + { + References = references, + ExpiresAt = DateTime.UtcNow.Add(cacheDuration) + }; + + var stream = File.Create(cachePath); + await using (stream.ConfigureAwait(false)) + { + await JsonSerializer.SerializeAsync(stream, cache, JsonDefaults.Options, cancellationToken).ConfigureAwait(false); + } + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Failed to save similar items cache to {CachePath}", cachePath); + } + } + + private sealed class SimilarItemsCache + { + public List<SimilarItemReference>? References { get; set; } + + public DateTime ExpiresAt { get; set; } + } + + private sealed class StringTupleComparer : IEqualityComparer<(string Key, string Value)> + { + public static readonly StringTupleComparer Instance = new(); + + public bool Equals((string Key, string Value) x, (string Key, string Value) y) + => string.Equals(x.Key, y.Key, StringComparison.OrdinalIgnoreCase) && + string.Equals(x.Value, y.Value, StringComparison.OrdinalIgnoreCase); + + public int GetHashCode((string Key, string Value) obj) + => HashCode.Combine( + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Key), + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Value)); + } +} 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/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..2e0b982152 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -953,6 +953,17 @@ public sealed partial class BaseItemRepository } } + if (filter.HasAnyProviderIds is not null && filter.HasAnyProviderIds.Count > 0) + { + var includeAny = filter.HasAnyProviderIds + .SelectMany(kvp => kvp.Value.Select(v => $"{kvp.Key}:{v}")) + .ToArray(); + if (includeAny.Length > 0) + { + baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.Any(f => includeAny.Contains(f))); + } + } + if (filter.HasImdbId.HasValue) { baseQuery = filter.HasImdbId.Value diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs index fa82ea8663..8ae578b228 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -351,6 +351,8 @@ namespace MediaBrowser.Controller.Entities public Dictionary<string, string>? HasAnyProviderId { get; set; } + public Dictionary<string, string[]>? HasAnyProviderIds { get; set; } + public Guid[] AlbumArtistIds { get; set; } public Guid[] BoxSetLibraryFolders { get; set; } diff --git a/MediaBrowser.Controller/Library/ILocalSimilarItemsProvider.cs b/MediaBrowser.Controller/Library/ILocalSimilarItemsProvider.cs new file mode 100644 index 0000000000..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/Providers/IProviderManager.cs b/MediaBrowser.Controller/Providers/IProviderManager.cs index 0d3a334dfb..c87f09a117 100644 --- a/MediaBrowser.Controller/Providers/IProviderManager.cs +++ b/MediaBrowser.Controller/Providers/IProviderManager.cs @@ -144,6 +144,17 @@ namespace MediaBrowser.Controller.Providers where T : BaseItem; /// <summary> + /// Gets the metadata providers for the provided item. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="libraryOptions">The library options.</param> + /// <param name="includeDisabled">Whether to include disabled providers.</param> + /// <typeparam name="T">The type of metadata provider.</typeparam> + /// <returns>The metadata providers.</returns> + IEnumerable<IMetadataProvider<T>> GetMetadataProviders<T>(BaseItem item, LibraryOptions libraryOptions, bool includeDisabled) + where T : BaseItem; + + /// <summary> /// Gets the metadata savers for the provided item. /// </summary> /// <param name="item">The item.</param> diff --git a/MediaBrowser.Model/Configuration/MetadataPluginType.cs b/MediaBrowser.Model/Configuration/MetadataPluginType.cs index 670d6e3837..476060ceef 100644 --- a/MediaBrowser.Model/Configuration/MetadataPluginType.cs +++ b/MediaBrowser.Model/Configuration/MetadataPluginType.cs @@ -15,6 +15,8 @@ namespace MediaBrowser.Model.Configuration MetadataSaver, SubtitleFetcher, LyricFetcher, - MediaSegmentProvider + MediaSegmentProvider, + LocalSimilarityProvider, + SimilarityProvider } } diff --git a/MediaBrowser.Model/Configuration/TypeOptions.cs b/MediaBrowser.Model/Configuration/TypeOptions.cs index d0179e5aab..3aa85034e5 100644 --- a/MediaBrowser.Model/Configuration/TypeOptions.cs +++ b/MediaBrowser.Model/Configuration/TypeOptions.cs @@ -304,11 +304,13 @@ namespace MediaBrowser.Model.Configuration public TypeOptions() { - MetadataFetchers = Array.Empty<string>(); - MetadataFetcherOrder = Array.Empty<string>(); - ImageFetchers = Array.Empty<string>(); - ImageFetcherOrder = Array.Empty<string>(); - ImageOptions = Array.Empty<ImageOption>(); + MetadataFetchers = []; + MetadataFetcherOrder = []; + ImageFetchers = []; + ImageFetcherOrder = []; + ImageOptions = []; + SimilarItemProviders = []; + SimilarItemProviderOrder = []; } public string Type { get; set; } @@ -323,6 +325,10 @@ namespace MediaBrowser.Model.Configuration public ImageOption[] ImageOptions { get; set; } + public string[] SimilarItemProviders { get; set; } + + public string[] SimilarItemProviderOrder { get; set; } + public ImageOption GetImageOptions(ImageType type) { foreach (var i in ImageOptions) diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index 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/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; } |
