mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-05-16 05:36:52 +01:00
Merge pull request #15970 from Shadowghost/similarity
Implement similarity providers
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>())
|
||||
|
||||
@@ -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; } = [];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
50
MediaBrowser.Controller/Library/ISimilarItemsManager.cs
Normal file
50
MediaBrowser.Controller/Library/ISimilarItemsManager.cs
Normal file
@@ -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);
|
||||
}
|
||||
26
MediaBrowser.Controller/Library/ISimilarItemsProvider.cs
Normal file
26
MediaBrowser.Controller/Library/ISimilarItemsProvider.cs
Normal file
@@ -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;
|
||||
}
|
||||
22
MediaBrowser.Controller/Library/SimilarItemReference.cs
Normal file
22
MediaBrowser.Controller/Library/SimilarItemReference.cs
Normal file
@@ -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; }
|
||||
}
|
||||
37
MediaBrowser.Controller/Library/SimilarItemsQuery.cs
Normal file
37
MediaBrowser.Controller/Library/SimilarItemsQuery.cs
Normal file
@@ -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; } = [];
|
||||
}
|
||||
@@ -143,6 +143,17 @@ namespace MediaBrowser.Controller.Providers
|
||||
IEnumerable<IMetadataProvider<T>> GetMetadataProviders<T>(BaseItem item, LibraryOptions libraryOptions)
|
||||
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>
|
||||
|
||||
@@ -15,6 +15,8 @@ namespace MediaBrowser.Model.Configuration
|
||||
MetadataSaver,
|
||||
SubtitleFetcher,
|
||||
LyricFetcher,
|
||||
MediaSegmentProvider
|
||||
MediaSegmentProvider,
|
||||
LocalSimilarityProvider,
|
||||
SimilarityProvider
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 60 KiB |
@@ -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>.
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -504,6 +504,54 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
||||
return searchResults?.Results;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user