diff options
28 files changed, 1205 insertions, 231 deletions
diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml new file mode 100644 index 000000000..6e370819a --- /dev/null +++ b/.github/workflows/openapi.yml @@ -0,0 +1,121 @@ +name: OpenAPI +on: + push: + branches: + - master + pull_request: + +jobs: + openapi-head: + name: OpenAPI - HEAD + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v2 + - name: Setup .NET Core + uses: actions/setup-dotnet@v1 + with: + dotnet-version: '6.0.x' + include-prerelease: true + - name: Generate openapi.json + run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests" + - name: Upload openapi.json + uses: actions/upload-artifact@v2 + with: + name: openapi-head + retention-days: 14 + if-no-files-found: error + path: tests/Jellyfin.Server.Integration.Tests/bin/Release/net6.0/openapi.json + + openapi-base: + name: OpenAPI - BASE + if: ${{ github.base_ref != '' }} + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v2 + with: + ref: ${{ github.base_ref }} + - name: Setup .NET Core + uses: actions/setup-dotnet@v1 + with: + dotnet-version: '6.0.x' + include-prerelease: true + - name: Generate openapi.json + run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests" + - name: Upload openapi.json + uses: actions/upload-artifact@v2 + with: + name: openapi-base + retention-days: 14 + if-no-files-found: error + path: tests/Jellyfin.Server.Integration.Tests/bin/Release/net6.0/openapi.json + + openapi-diff: + name: OpenAPI - Difference + if: ${{ github.event_name == 'pull_request' }} + runs-on: ubuntu-latest + needs: + - openapi-head + - openapi-base + steps: + - name: Download openapi-head + uses: actions/download-artifact@v2 + with: + name: openapi-head + path: openapi-head + - name: Download openapi-base + uses: actions/download-artifact@v2 + with: + name: openapi-base + path: openapi-base + - name: Workaround openapi-diff issue + run: | + sed -i 's/"allOf"/"oneOf"/g' openapi-head/openapi.json + sed -i 's/"allOf"/"oneOf"/g' openapi-base/openapi.json + - name: Calculate OpenAPI difference + uses: docker://openapitools/openapi-diff + continue-on-error: true + with: + args: --fail-on-changed --markdown openapi-changes.md openapi-base/openapi.json openapi-head/openapi.json + - id: read-diff + name: Read openapi-diff output + run: | + body=$(cat openapi-changes.md) + body="${body//'%'/'%25'}" + body="${body//$'\n'/'%0A'}" + body="${body//$'\r'/'%0D'}" + echo ::set-output name=body::$body + - name: Find difference comment + uses: peter-evans/find-comment@v1 + id: find-comment + with: + issue-number: ${{ github.event.pull_request.number }} + direction: last + body-includes: openapi-diff-workflow-comment + - name: Reply or edit difference comment (changed) + uses: peter-evans/create-or-update-comment@v1.4.5 + if: ${{ steps.read-diff.outputs.body != '' }} + with: + issue-number: ${{ github.event.pull_request.number }} + comment-id: ${{ steps.find-comment.outputs.comment-id }} + edit-mode: replace + body: | + <!--openapi-diff-workflow-comment--> + <details> + <summary>Changes in OpenAPI specification found. Expand to see details.</summary> + + ${{ steps.read-diff.outputs.body }} + + </details> + - name: Edit difference comment (unchanged) + uses: peter-evans/create-or-update-comment@v1.4.5 + if: ${{ steps.read-diff.outputs.body == '' && steps.find-comment.outputs.comment-id != '' }} + with: + issue-number: ${{ github.event.pull_request.number }} + comment-id: ${{ steps.find-comment.outputs.comment-id }} + edit-mode: replace + body: | + <!--openapi-diff-workflow-comment--> + + No changes to OpenAPI specification found. See history of this comment for previous changes. diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs index 6355f8b1b..5ddcf37fe 100644 --- a/Emby.Naming/Common/NamingOptions.cs +++ b/Emby.Naming/Common/NamingOptions.cs @@ -253,6 +253,8 @@ namespace Emby.Naming.Common }, // <!-- foo.ep01, foo.EP_01 --> new EpisodeExpression(@"[\._ -]()[Ee][Pp]_?([0-9]+)([^\\/]*)$"), + // <!-- foo.E01., foo.e01. --> + new EpisodeExpression(@"[^\\/]*?()\.?[Ee]([0-9]+)\.([^\\/]*)$"), new EpisodeExpression("(?<year>[0-9]{4})[\\.-](?<month>[0-9]{2})[\\.-](?<day>[0-9]{2})", true) { DateTimeFormats = new[] @@ -371,6 +373,20 @@ namespace Emby.Naming.Common IsOptimistic = true, IsNamed = true }, + + // Series and season only expression + // "the show/season 1", "the show/s01" + new EpisodeExpression(@"(.*(\\|\/))*(?<seriesname>.+)\/[Ss](eason)?[\. _\-]*(?<seasonnumber>[0-9]+)") + { + IsNamed = true + }, + + // Series and season only expression + // "the show S01", "the show season 1" + new EpisodeExpression(@"(.*(\\|\/))*(?<seriesname>.+)[\. _\-]+[sS](eason)?[\. _\-]*(?<seasonnumber>[0-9]+)") + { + IsNamed = true + }, }; EpisodeWithoutSeasonExpressions = new[] diff --git a/Emby.Naming/TV/SeriesInfo.cs b/Emby.Naming/TV/SeriesInfo.cs new file mode 100644 index 000000000..5d6cb4bd3 --- /dev/null +++ b/Emby.Naming/TV/SeriesInfo.cs @@ -0,0 +1,29 @@ +namespace Emby.Naming.TV +{ + /// <summary> + /// Holder object for Series information. + /// </summary> + public class SeriesInfo + { + /// <summary> + /// Initializes a new instance of the <see cref="SeriesInfo"/> class. + /// </summary> + /// <param name="path">Path to the file.</param> + public SeriesInfo(string path) + { + Path = path; + } + + /// <summary> + /// Gets or sets the path. + /// </summary> + /// <value>The path.</value> + public string Path { get; set; } + + /// <summary> + /// Gets or sets the name of the series. + /// </summary> + /// <value>The name of the series.</value> + public string? Name { get; set; } + } +} diff --git a/Emby.Naming/TV/SeriesPathParser.cs b/Emby.Naming/TV/SeriesPathParser.cs new file mode 100644 index 000000000..a62e5f4d6 --- /dev/null +++ b/Emby.Naming/TV/SeriesPathParser.cs @@ -0,0 +1,61 @@ +using System.Globalization; +using Emby.Naming.Common; + +namespace Emby.Naming.TV +{ + /// <summary> + /// Used to parse information about series from paths containing more information that only the series name. + /// Uses the same regular expressions as the EpisodePathParser but have different success criteria. + /// </summary> + public static class SeriesPathParser + { + /// <summary> + /// Parses information about series from path. + /// </summary> + /// <param name="options"><see cref="NamingOptions"/> object containing EpisodeExpressions and MultipleEpisodeExpressions.</param> + /// <param name="path">Path.</param> + /// <returns>Returns <see cref="SeriesPathParserResult"/> object.</returns> + public static SeriesPathParserResult Parse(NamingOptions options, string path) + { + SeriesPathParserResult? result = null; + + foreach (var expression in options.EpisodeExpressions) + { + var currentResult = Parse(path, expression); + if (currentResult.Success) + { + result = currentResult; + break; + } + } + + if (result != null) + { + if (!string.IsNullOrEmpty(result.SeriesName)) + { + result.SeriesName = result.SeriesName.Trim(' ', '_', '.', '-'); + } + } + + return result ?? new SeriesPathParserResult(); + } + + private static SeriesPathParserResult Parse(string name, EpisodeExpression expression) + { + var result = new SeriesPathParserResult(); + + var match = expression.Regex.Match(name); + + if (match.Success && match.Groups.Count >= 3) + { + if (expression.IsNamed) + { + result.SeriesName = match.Groups["seriesname"].Value; + result.Success = !string.IsNullOrEmpty(result.SeriesName) && !string.IsNullOrEmpty(match.Groups["seasonnumber"]?.Value); + } + } + + return result; + } + } +} diff --git a/Emby.Naming/TV/SeriesPathParserResult.cs b/Emby.Naming/TV/SeriesPathParserResult.cs new file mode 100644 index 000000000..44cd2fdfa --- /dev/null +++ b/Emby.Naming/TV/SeriesPathParserResult.cs @@ -0,0 +1,19 @@ +namespace Emby.Naming.TV +{ + /// <summary> + /// Holder object for <see cref="SeriesPathParser"/> result. + /// </summary> + public class SeriesPathParserResult + { + /// <summary> + /// Gets or sets the name of the series. + /// </summary> + /// <value>The name of the series.</value> + public string? SeriesName { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether parsing was successful. + /// </summary> + public bool Success { get; set; } + } +} diff --git a/Emby.Naming/TV/SeriesResolver.cs b/Emby.Naming/TV/SeriesResolver.cs new file mode 100644 index 000000000..156a03c9e --- /dev/null +++ b/Emby.Naming/TV/SeriesResolver.cs @@ -0,0 +1,49 @@ +using System.IO; +using System.Text.RegularExpressions; +using Emby.Naming.Common; + +namespace Emby.Naming.TV +{ + /// <summary> + /// Used to resolve information about series from path. + /// </summary> + public static class SeriesResolver + { + /// <summary> + /// Regex that matches strings of at least 2 characters separated by a dot or underscore. + /// Used for removing separators between words, i.e turns "The_show" into "The show" while + /// preserving namings like "S.H.O.W". + /// </summary> + private static readonly Regex _seriesNameRegex = new Regex(@"((?<a>[^\._]{2,})[\._]*)|([\._](?<b>[^\._]{2,}))"); + + /// <summary> + /// Resolve information about series from path. + /// </summary> + /// <param name="options"><see cref="NamingOptions"/> object passed to <see cref="SeriesPathParser"/>.</param> + /// <param name="path">Path to series.</param> + /// <returns>SeriesInfo.</returns> + public static SeriesInfo Resolve(NamingOptions options, string path) + { + string seriesName = Path.GetFileName(path); + + SeriesPathParserResult result = SeriesPathParser.Parse(options, path); + if (result.Success) + { + if (!string.IsNullOrEmpty(result.SeriesName)) + { + seriesName = result.SeriesName; + } + } + + if (!string.IsNullOrEmpty(seriesName)) + { + seriesName = _seriesNameRegex.Replace(seriesName, "${a} ${b}").Trim(); + } + + return new SeriesInfo(path) + { + Name = seriesName + }; + } + } +} diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 6fd152a42..512700ac2 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -147,25 +147,20 @@ namespace Emby.Server.Implementations /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param> /// <param name="options">Instance of the <see cref="IStartupOptions"/> interface.</param> /// <param name="startupConfig">The <see cref="IConfiguration" /> interface.</param> - /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> - /// <param name="serviceCollection">Instance of the <see cref="IServiceCollection"/> interface.</param> public ApplicationHost( IServerApplicationPaths applicationPaths, ILoggerFactory loggerFactory, IStartupOptions options, - IConfiguration startupConfig, - IFileSystem fileSystem, - IServiceCollection serviceCollection) + IConfiguration startupConfig) { ApplicationPaths = applicationPaths; LoggerFactory = loggerFactory; _startupOptions = options; _startupConfig = startupConfig; - _fileSystemManager = fileSystem; - ServiceCollection = serviceCollection; + _fileSystemManager = new ManagedFileSystem(LoggerFactory.CreateLogger<ManagedFileSystem>(), applicationPaths); Logger = LoggerFactory.CreateLogger<ApplicationHost>(); - fileSystem.AddShortcutHandler(new MbLinkShortcutHandler(fileSystem)); + _fileSystemManager.AddShortcutHandler(new MbLinkShortcutHandler(_fileSystemManager)); ApplicationVersion = typeof(ApplicationHost).Assembly.GetName().Version; ApplicationVersionString = ApplicationVersion.ToString(3); @@ -230,8 +225,6 @@ namespace Emby.Server.Implementations /// </summary> protected ILogger<ApplicationHost> Logger { get; } - protected IServiceCollection ServiceCollection { get; } - /// <summary> /// Gets the logger factory. /// </summary> @@ -521,7 +514,7 @@ namespace Emby.Server.Implementations } /// <inheritdoc/> - public void Init() + public void Init(IServiceCollection serviceCollection) { DiscoverTypes(); @@ -551,128 +544,129 @@ namespace Emby.Server.Implementations CertificatePath = networkConfiguration.CertificatePath; Certificate = GetCertificate(CertificatePath, networkConfiguration.CertificatePassword); - RegisterServices(); + RegisterServices(serviceCollection); - _pluginManager.RegisterServices(ServiceCollection); + _pluginManager.RegisterServices(serviceCollection); } /// <summary> /// Registers services/resources with the service collection that will be available via DI. /// </summary> - protected virtual void RegisterServices() + /// <param name="serviceCollection">Instance of the <see cref="IServiceCollection"/> interface.</param> + protected virtual void RegisterServices(IServiceCollection serviceCollection) { - ServiceCollection.AddSingleton(_startupOptions); + serviceCollection.AddSingleton(_startupOptions); - ServiceCollection.AddMemoryCache(); + serviceCollection.AddMemoryCache(); - ServiceCollection.AddSingleton<IServerConfigurationManager>(ConfigurationManager); - ServiceCollection.AddSingleton<IConfigurationManager>(ConfigurationManager); - ServiceCollection.AddSingleton<IApplicationHost>(this); - ServiceCollection.AddSingleton<IPluginManager>(_pluginManager); - ServiceCollection.AddSingleton<IApplicationPaths>(ApplicationPaths); + serviceCollection.AddSingleton<IServerConfigurationManager>(ConfigurationManager); + serviceCollection.AddSingleton<IConfigurationManager>(ConfigurationManager); + serviceCollection.AddSingleton<IApplicationHost>(this); + serviceCollection.AddSingleton<IPluginManager>(_pluginManager); + serviceCollection.AddSingleton<IApplicationPaths>(ApplicationPaths); - ServiceCollection.AddSingleton(_fileSystemManager); - ServiceCollection.AddSingleton<TmdbClientManager>(); + serviceCollection.AddSingleton(_fileSystemManager); + serviceCollection.AddSingleton<TmdbClientManager>(); - ServiceCollection.AddSingleton(NetManager); + serviceCollection.AddSingleton(NetManager); - ServiceCollection.AddSingleton<ITaskManager, TaskManager>(); + serviceCollection.AddSingleton<ITaskManager, TaskManager>(); - ServiceCollection.AddSingleton(_xmlSerializer); + serviceCollection.AddSingleton(_xmlSerializer); - ServiceCollection.AddSingleton<IStreamHelper, StreamHelper>(); + serviceCollection.AddSingleton<IStreamHelper, StreamHelper>(); - ServiceCollection.AddSingleton<ICryptoProvider, CryptographyProvider>(); + serviceCollection.AddSingleton<ICryptoProvider, CryptographyProvider>(); - ServiceCollection.AddSingleton<ISocketFactory, SocketFactory>(); + serviceCollection.AddSingleton<ISocketFactory, SocketFactory>(); - ServiceCollection.AddSingleton<IInstallationManager, InstallationManager>(); + serviceCollection.AddSingleton<IInstallationManager, InstallationManager>(); - ServiceCollection.AddSingleton<IZipClient, ZipClient>(); + serviceCollection.AddSingleton<IZipClient, ZipClient>(); - ServiceCollection.AddSingleton<IServerApplicationHost>(this); - ServiceCollection.AddSingleton<IServerApplicationPaths>(ApplicationPaths); + serviceCollection.AddSingleton<IServerApplicationHost>(this); + serviceCollection.AddSingleton<IServerApplicationPaths>(ApplicationPaths); - ServiceCollection.AddSingleton<ILocalizationManager, LocalizationManager>(); + serviceCollection.AddSingleton<ILocalizationManager, LocalizationManager>(); - ServiceCollection.AddSingleton<IBlurayExaminer, BdInfoExaminer>(); + serviceCollection.AddSingleton<IBlurayExaminer, BdInfoExaminer>(); - ServiceCollection.AddSingleton<IUserDataRepository, SqliteUserDataRepository>(); - ServiceCollection.AddSingleton<IUserDataManager, UserDataManager>(); + serviceCollection.AddSingleton<IUserDataRepository, SqliteUserDataRepository>(); + serviceCollection.AddSingleton<IUserDataManager, UserDataManager>(); - ServiceCollection.AddSingleton<IItemRepository, SqliteItemRepository>(); + serviceCollection.AddSingleton<IItemRepository, SqliteItemRepository>(); - ServiceCollection.AddSingleton<IMediaEncoder, MediaBrowser.MediaEncoding.Encoder.MediaEncoder>(); - ServiceCollection.AddSingleton<EncodingHelper>(); + serviceCollection.AddSingleton<IMediaEncoder, MediaBrowser.MediaEncoding.Encoder.MediaEncoder>(); + serviceCollection.AddSingleton<EncodingHelper>(); // TODO: Refactor to eliminate the circular dependencies here so that Lazy<T> isn't required - ServiceCollection.AddTransient(provider => new Lazy<ILibraryMonitor>(provider.GetRequiredService<ILibraryMonitor>)); - ServiceCollection.AddTransient(provider => new Lazy<IProviderManager>(provider.GetRequiredService<IProviderManager>)); - ServiceCollection.AddTransient(provider => new Lazy<IUserViewManager>(provider.GetRequiredService<IUserViewManager>)); - ServiceCollection.AddSingleton<ILibraryManager, LibraryManager>(); + serviceCollection.AddTransient(provider => new Lazy<ILibraryMonitor>(provider.GetRequiredService<ILibraryMonitor>)); + serviceCollection.AddTransient(provider => new Lazy<IProviderManager>(provider.GetRequiredService<IProviderManager>)); + serviceCollection.AddTransient(provider => new Lazy<IUserViewManager>(provider.GetRequiredService<IUserViewManager>)); + serviceCollection.AddSingleton<ILibraryManager, LibraryManager>(); - ServiceCollection.AddSingleton<IMusicManager, MusicManager>(); + serviceCollection.AddSingleton<IMusicManager, MusicManager>(); - ServiceCollection.AddSingleton<ILibraryMonitor, LibraryMonitor>(); + serviceCollection.AddSingleton<ILibraryMonitor, LibraryMonitor>(); - ServiceCollection.AddSingleton<ISearchEngine, SearchEngine>(); + serviceCollection.AddSingleton<ISearchEngine, SearchEngine>(); - ServiceCollection.AddSingleton<IWebSocketManager, WebSocketManager>(); + serviceCollection.AddSingleton<IWebSocketManager, WebSocketManager>(); - ServiceCollection.AddSingleton<IImageProcessor, ImageProcessor>(); + serviceCollection.AddSingleton<IImageProcessor, ImageProcessor>(); - ServiceCollection.AddSingleton<ITVSeriesManager, TVSeriesManager>(); + serviceCollection.AddSingleton<ITVSeriesManager, TVSeriesManager>(); - ServiceCollection.AddSingleton<IMediaSourceManager, MediaSourceManager>(); + serviceCollection.AddSingleton<IMediaSourceManager, MediaSourceManager>(); - ServiceCollection.AddSingleton<ISubtitleManager, SubtitleManager>(); + serviceCollection.AddSingleton<ISubtitleManager, SubtitleManager>(); - ServiceCollection.AddSingleton<IProviderManager, ProviderManager>(); + serviceCollection.AddSingleton<IProviderManager, ProviderManager>(); // TODO: Refactor to eliminate the circular dependency here so that Lazy<T> isn't required - ServiceCollection.AddTransient(provider => new Lazy<ILiveTvManager>(provider.GetRequiredService<ILiveTvManager>)); - ServiceCollection.AddSingleton<IDtoService, DtoService>(); + serviceCollection.AddTransient(provider => new Lazy<ILiveTvManager>(provider.GetRequiredService<ILiveTvManager>)); + serviceCollection.AddSingleton<IDtoService, DtoService>(); - ServiceCollection.AddSingleton<IChannelManager, ChannelManager>(); + serviceCollection.AddSingleton<IChannelManager, ChannelManager>(); - ServiceCollection.AddSingleton<ISessionManager, SessionManager>(); + serviceCollection.AddSingleton<ISessionManager, SessionManager>(); - ServiceCollection.AddSingleton<IDlnaManager, DlnaManager>(); + serviceCollection.AddSingleton<IDlnaManager, DlnaManager>(); - ServiceCollection.AddSingleton<ICollectionManager, CollectionManager>(); + serviceCollection.AddSingleton<ICollectionManager, CollectionManager>(); - ServiceCollection.AddSingleton<IPlaylistManager, PlaylistManager>(); + serviceCollection.AddSingleton<IPlaylistManager, PlaylistManager>(); - ServiceCollection.AddSingleton<ISyncPlayManager, SyncPlayManager>(); + serviceCollection.AddSingleton<ISyncPlayManager, SyncPlayManager>(); - ServiceCollection.AddSingleton<LiveTvDtoService>(); - ServiceCollection.AddSingleton<ILiveTvManager, LiveTvManager>(); + serviceCollection.AddSingleton<LiveTvDtoService>(); + serviceCollection.AddSingleton<ILiveTvManager, LiveTvManager>(); - ServiceCollection.AddSingleton<IUserViewManager, UserViewManager>(); + serviceCollection.AddSingleton<IUserViewManager, UserViewManager>(); - ServiceCollection.AddSingleton<INotificationManager, NotificationManager>(); + serviceCollection.AddSingleton<INotificationManager, NotificationManager>(); - ServiceCollection.AddSingleton<IDeviceDiscovery, DeviceDiscovery>(); + serviceCollection.AddSingleton<IDeviceDiscovery, DeviceDiscovery>(); - ServiceCollection.AddSingleton<IChapterManager, ChapterManager>(); + serviceCollection.AddSingleton<IChapterManager, ChapterManager>(); - ServiceCollection.AddSingleton<IEncodingManager, MediaEncoder.EncodingManager>(); + serviceCollection.AddSingleton<IEncodingManager, MediaEncoder.EncodingManager>(); - ServiceCollection.AddScoped<ISessionContext, SessionContext>(); + serviceCollection.AddScoped<ISessionContext, SessionContext>(); - ServiceCollection.AddSingleton<IAuthService, AuthService>(); - ServiceCollection.AddSingleton<IQuickConnect, QuickConnectManager>(); + serviceCollection.AddSingleton<IAuthService, AuthService>(); + serviceCollection.AddSingleton<IQuickConnect, QuickConnectManager>(); - ServiceCollection.AddSingleton<ISubtitleEncoder, MediaBrowser.MediaEncoding.Subtitles.SubtitleEncoder>(); + serviceCollection.AddSingleton<ISubtitleEncoder, MediaBrowser.MediaEncoding.Subtitles.SubtitleEncoder>(); - ServiceCollection.AddSingleton<IAttachmentExtractor, MediaBrowser.MediaEncoding.Attachments.AttachmentExtractor>(); + serviceCollection.AddSingleton<IAttachmentExtractor, MediaBrowser.MediaEncoding.Attachments.AttachmentExtractor>(); - ServiceCollection.AddSingleton<TranscodingJobHelper>(); - ServiceCollection.AddScoped<MediaInfoHelper>(); - ServiceCollection.AddScoped<AudioHelper>(); - ServiceCollection.AddScoped<DynamicHlsHelper>(); + serviceCollection.AddSingleton<TranscodingJobHelper>(); + serviceCollection.AddScoped<MediaInfoHelper>(); + serviceCollection.AddScoped<AudioHelper>(); + serviceCollection.AddScoped<DynamicHlsHelper>(); - ServiceCollection.AddSingleton<IDirectoryService, DirectoryService>(); + serviceCollection.AddSingleton<IDirectoryService, DirectoryService>(); } /// <summary> diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs index b7fbe01c5..e62890083 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs @@ -54,6 +54,8 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV return null; } + var seriesInfo = Naming.TV.SeriesResolver.Resolve(_libraryManager.GetNamingOptions(), args.Path); + var collectionType = args.GetCollectionType(); if (string.Equals(collectionType, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase)) { @@ -63,7 +65,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV return new Series { Path = args.Path, - Name = Path.GetFileName(args.Path) + Name = seriesInfo.Name }; } } @@ -80,7 +82,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV return new Series { Path = args.Path, - Name = Path.GetFileName(args.Path) + Name = seriesInfo.Name }; } @@ -94,7 +96,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV return new Series { Path = args.Path, - Name = Path.GetFileName(args.Path) + Name = seriesInfo.Name }; } } diff --git a/Emby.Server.Implementations/Localization/countries.json b/Emby.Server.Implementations/Localization/countries.json index b08a3ae79..22ffc5e09 100644 --- a/Emby.Server.Implementations/Localization/countries.json +++ b/Emby.Server.Implementations/Localization/countries.json @@ -630,7 +630,7 @@ "TwoLetterISORegionName": "MD" }, { - "DisplayName": "Réunion", + "DisplayName": "Réunion", "Name": "RE", "ThreeLetterISORegionName": "REU", "TwoLetterISORegionName": "RE" diff --git a/Emby.Server.Implementations/Localization/iso6392.txt b/Emby.Server.Implementations/Localization/iso6392.txt index 488901822..66fba3330 100644 --- a/Emby.Server.Implementations/Localization/iso6392.txt +++ b/Emby.Server.Implementations/Localization/iso6392.txt @@ -349,7 +349,8 @@ pli||pi|Pali|pali pol||pl|Polish|polonais pon|||Pohnpeian|pohnpei por||pt|Portuguese|portugais -pob||pt-br|Portuguese (Brazil)|portugais +pop||pt-pt|Portuguese (Portugal)|portugais (pt-pt) +pob||pt-br|Portuguese (Brazil)|portugais (pt-br) pra|||Prakrit languages|prâkrit, langues pro|||Provençal, Old (to 1500)|provençal ancien (jusqu'à 1500) pus||ps|Pushto; Pashto|pachto diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs index 21bd9ba01..67e50b92d 100644 --- a/Jellyfin.Server/CoreAppHost.cs +++ b/Jellyfin.Server/CoreAppHost.cs @@ -22,7 +22,6 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Security; using MediaBrowser.Model.Activity; -using MediaBrowser.Model.IO; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -42,67 +41,61 @@ namespace Jellyfin.Server /// <param name="loggerFactory">The <see cref="ILoggerFactory" /> to be used by the <see cref="CoreAppHost" />.</param> /// <param name="options">The <see cref="StartupOptions" /> to be used by the <see cref="CoreAppHost" />.</param> /// <param name="startupConfig">The <see cref="IConfiguration" /> to be used by the <see cref="CoreAppHost" />.</param> - /// <param name="fileSystem">The <see cref="IFileSystem" /> to be used by the <see cref="CoreAppHost" />.</param> - /// <param name="collection">The <see cref="IServiceCollection"/> to be used by the <see cref="CoreAppHost"/>.</param> public CoreAppHost( IServerApplicationPaths applicationPaths, ILoggerFactory loggerFactory, IStartupOptions options, - IConfiguration startupConfig, - IFileSystem fileSystem, - IServiceCollection collection) + IConfiguration startupConfig) : base( applicationPaths, loggerFactory, options, - startupConfig, - fileSystem, - collection) + startupConfig) { } /// <inheritdoc/> - protected override void RegisterServices() + protected override void RegisterServices(IServiceCollection serviceCollection) { // Register an image encoder bool useSkiaEncoder = SkiaEncoder.IsNativeLibAvailable(); Type imageEncoderType = useSkiaEncoder ? typeof(SkiaEncoder) : typeof(NullImageEncoder); - ServiceCollection.AddSingleton(typeof(IImageEncoder), imageEncoderType); + serviceCollection.AddSingleton(typeof(IImageEncoder), imageEncoderType); // Log a warning if the Skia encoder could not be used if (!useSkiaEncoder) { - Logger.LogWarning($"Skia not available. Will fallback to {nameof(NullImageEncoder)}."); + Logger.LogWarning("Skia not available. Will fallback to {ImageEncoder}.", nameof(NullImageEncoder)); } - ServiceCollection.AddDbContextPool<JellyfinDb>( + serviceCollection.AddDbContextPool<JellyfinDb>( options => options .UseLoggerFactory(LoggerFactory) .UseSqlite($"Filename={Path.Combine(ApplicationPaths.DataPath, "jellyfin.db")}")); - ServiceCollection.AddEventServices(); - ServiceCollection.AddSingleton<IBaseItemManager, BaseItemManager>(); - ServiceCollection.AddSingleton<IEventManager, EventManager>(); - ServiceCollection.AddSingleton<JellyfinDbProvider>(); + serviceCollection.AddEventServices(); + serviceCollection.AddSingleton<IBaseItemManager, BaseItemManager>(); + serviceCollection.AddSingleton<IEventManager, EventManager>(); + serviceCollection.AddSingleton<JellyfinDbProvider>(); - ServiceCollection.AddSingleton<IActivityManager, ActivityManager>(); - ServiceCollection.AddSingleton<IUserManager, UserManager>(); - ServiceCollection.AddSingleton<IDisplayPreferencesManager, DisplayPreferencesManager>(); - ServiceCollection.AddSingleton<IDeviceManager, DeviceManager>(); + serviceCollection.AddSingleton<IActivityManager, ActivityManager>(); + serviceCollection.AddSingleton<IUserManager, UserManager>(); + serviceCollection.AddSingleton<IDisplayPreferencesManager, DisplayPreferencesManager>(); + serviceCollection.AddSingleton<IDeviceManager, DeviceManager>(); // TODO search the assemblies instead of adding them manually? - ServiceCollection.AddSingleton<IWebSocketListener, SessionWebSocketListener>(); - ServiceCollection.AddSingleton<IWebSocketListener, ActivityLogWebSocketListener>(); - ServiceCollection.AddSingleton<IWebSocketListener, ScheduledTasksWebSocketListener>(); - ServiceCollection.AddSingleton<IWebSocketListener, SessionInfoWebSocketListener>(); + serviceCollection.AddSingleton<IWebSocketListener, SessionWebSocketListener>(); + serviceCollection.AddSingleton<IWebSocketListener, ActivityLogWebSocketListener>(); + serviceCollection.AddSingleton<IWebSocketListener, ScheduledTasksWebSocketListener>(); + serviceCollection.AddSingleton<IWebSocketListener, SessionInfoWebSocketListener>(); - ServiceCollection.AddSingleton<IAuthorizationContext, AuthorizationContext>(); + serviceCollection.AddSingleton<IAuthorizationContext, AuthorizationContext>(); - ServiceCollection.AddScoped<IAuthenticationManager, AuthenticationManager>(); + serviceCollection.AddScoped<IAuthenticationManager, AuthenticationManager>(); - base.RegisterServices(); + base.RegisterServices(serviceCollection); } /// <inheritdoc /> diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index 45699f3af..5f848be9e 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -10,7 +10,6 @@ using System.Threading; using System.Threading.Tasks; using CommandLine; using Emby.Server.Implementations; -using Emby.Server.Implementations.IO; using Jellyfin.Server.Implementations; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; @@ -157,34 +156,36 @@ namespace Jellyfin.Server ApplicationHost.LogEnvironmentInfo(_logger, appPaths); + // If hosting the web client, validate the client content path + if (startupConfig.HostWebClient()) + { + string? webContentPath = appPaths.WebPath; + if (!Directory.Exists(webContentPath) || !Directory.EnumerateFiles(webContentPath).Any()) + { + _logger.LogError( + "The server is expected to host the web client, but the provided content directory is either " + + "invalid or empty: {WebContentPath}. If you do not want to host the web client with the " + + "server, you may set the '--nowebclient' command line flag, or set" + + "'{ConfigKey}=false' in your config settings.", + webContentPath, + ConfigurationExtensions.HostWebClientKey); + Environment.ExitCode = 1; + return; + } + } + PerformStaticInitialization(); - var serviceCollection = new ServiceCollection(); var appHost = new CoreAppHost( appPaths, _loggerFactory, options, - startupConfig, - new ManagedFileSystem(_loggerFactory.CreateLogger<ManagedFileSystem>(), appPaths), - serviceCollection); + startupConfig); try { - // If hosting the web client, validate the client content path - if (startupConfig.HostWebClient()) - { - string? webContentPath = appHost.ConfigurationManager.ApplicationPaths.WebPath; - if (!Directory.Exists(webContentPath) || Directory.GetFiles(webContentPath).Length == 0) - { - throw new InvalidOperationException( - "The server is expected to host the web client, but the provided content directory is either " + - $"invalid or empty: {webContentPath}. If you do not want to host the web client with the " + - "server, you may set the '--nowebclient' command line flag, or set" + - $"'{ConfigurationExtensions.HostWebClientKey}=false' in your config settings."); - } - } - - appHost.Init(); + var serviceCollection = new ServiceCollection(); + appHost.Init(serviceCollection); var webHost = new WebHostBuilder().ConfigureWebHostBuilder(appHost, serviceCollection, options, startupConfig, appPaths).Build(); diff --git a/MediaBrowser.Common/IApplicationHost.cs b/MediaBrowser.Common/IApplicationHost.cs index 192a77611..e49ab41f4 100644 --- a/MediaBrowser.Common/IApplicationHost.cs +++ b/MediaBrowser.Common/IApplicationHost.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Reflection; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; namespace MediaBrowser.Common { @@ -137,7 +138,8 @@ namespace MediaBrowser.Common /// <summary> /// Initializes this instance. /// </summary> - void Init(); + /// <param name="serviceCollection">Instance of the <see cref="IServiceCollection"/> interface.</param> + void Init(IServiceCollection serviceCollection); /// <summary> /// Creates the instance. diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 838a9f2f8..02ee97b23 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -2345,7 +2345,7 @@ namespace MediaBrowser.Controller.Entities RemoveImages(new List<ItemImageInfo> { image }); } - public void RemoveImages(List<ItemImageInfo> deletedImages) + public void RemoveImages(IEnumerable<ItemImageInfo> deletedImages) { ImageInfos = ImageInfos.Except(deletedImages).ToArray(); } @@ -2495,11 +2495,11 @@ namespace MediaBrowser.Controller.Entities } /// <summary> - /// Adds the images. + /// Adds the images, updating metadata if they already are part of this item. /// </summary> /// <param name="imageType">Type of the image.</param> /// <param name="images">The images.</param> - /// <returns><c>true</c> if XXXX, <c>false</c> otherwise.</returns> + /// <returns><c>true</c> if images were added or updated, <c>false</c> otherwise.</returns> /// <exception cref="ArgumentException">Cannot call AddImages with chapter images.</exception> public bool AddImages(ImageType imageType, List<FileSystemMetadata> images) { @@ -2512,7 +2512,6 @@ namespace MediaBrowser.Controller.Entities .ToList(); var newImageList = new List<FileSystemMetadata>(); - var imageAdded = false; var imageUpdated = false; foreach (var newImage in images) @@ -2528,7 +2527,6 @@ namespace MediaBrowser.Controller.Entities if (existing == null) { newImageList.Add(newImage); - imageAdded = true; } else { @@ -2549,19 +2547,6 @@ namespace MediaBrowser.Controller.Entities } } - if (imageAdded || images.Count != existingImages.Count) - { - var newImagePaths = images.Select(i => i.FullName).ToList(); - - var deleted = existingImages - .FindAll(i => i.IsLocalFile && !newImagePaths.Contains(i.Path.AsSpan(), StringComparison.OrdinalIgnoreCase) && !File.Exists(i.Path)); - - if (deleted.Count > 0) - { - ImageInfos = ImageInfos.Except(deleted).ToArray(); - } - } - if (newImageList.Count > 0) { ImageInfos = ImageInfos.Concat(newImageList.Select(i => GetImageInfo(i, imageType))).ToArray(); diff --git a/MediaBrowser.Providers/Manager/ItemImageProvider.cs b/MediaBrowser.Providers/Manager/ItemImageProvider.cs index 39372acb9..8d5795f8e 100644 --- a/MediaBrowser.Providers/Manager/ItemImageProvider.cs +++ b/MediaBrowser.Providers/Manager/ItemImageProvider.cs @@ -1,7 +1,5 @@ #nullable disable -#pragma warning disable CA1002, CS1591 - using System; using System.Collections.Generic; using System.IO; @@ -25,6 +23,9 @@ using Microsoft.Extensions.Logging; namespace MediaBrowser.Providers.Manager { + /// <summary> + /// Utilities for managing images attached to items. + /// </summary> public class ItemImageProvider { private readonly ILogger _logger; @@ -47,6 +48,12 @@ namespace MediaBrowser.Providers.Manager ImageType.Thumb }; + /// <summary> + /// Initializes a new instance of the <see cref="ItemImageProvider"/> class. + /// </summary> + /// <param name="logger">The logger.</param> + /// <param name="providerManager">The provider manager for interacting with provider image references.</param> + /// <param name="fileSystem">The filesystem.</param> public ItemImageProvider(ILogger logger, IProviderManager providerManager, IFileSystem fileSystem) { _logger = logger; @@ -54,6 +61,13 @@ namespace MediaBrowser.Providers.Manager _fileSystem = fileSystem; } + /// <summary> + /// Verifies existing images have valid paths and adds any new local images provided. + /// </summary> + /// <param name="item">The <see cref="BaseItem"/> to validate images for.</param> + /// <param name="providers">The providers to use, must include <see cref="ILocalImageProvider"/>(s) for local scanning.</param> + /// <param name="directoryService">The directory service for <see cref="ILocalImageProvider"/>s to use.</param> + /// <returns><c>true</c> if changes were made to the item; otherwise <c>false</c>.</returns> public bool ValidateImages(BaseItem item, IEnumerable<IImageProvider> providers, IDirectoryService directoryService) { var hasChanges = false; @@ -73,6 +87,15 @@ namespace MediaBrowser.Providers.Manager return hasChanges; } + /// <summary> + /// Refreshes from the providers according to the given options. + /// </summary> + /// <param name="item">The <see cref="BaseItem"/> to gather images for.</param> + /// <param name="libraryOptions">The library options.</param> + /// <param name="providers">The providers to query for images.</param> + /// <param name="refreshOptions">The refresh options.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The refresh result.</returns> public async Task<RefreshResult> RefreshImages( BaseItem item, LibraryOptions libraryOptions, @@ -80,14 +103,16 @@ namespace MediaBrowser.Providers.Manager ImageRefreshOptions refreshOptions, CancellationToken cancellationToken) { + var oldBackdropImages = Array.Empty<ItemImageInfo>(); if (refreshOptions.IsReplacingImage(ImageType.Backdrop)) { - ClearImages(item, ImageType.Backdrop); + oldBackdropImages = item.GetImages(ImageType.Backdrop).ToArray(); } + var oldScreenshotImages = Array.Empty<ItemImageInfo>(); if (refreshOptions.IsReplacingImage(ImageType.Screenshot)) { - ClearImages(item, ImageType.Screenshot); + oldScreenshotImages = item.GetImages(ImageType.Screenshot).ToArray(); } var result = new RefreshResult { UpdateType = ItemUpdateType.None }; @@ -95,9 +120,9 @@ namespace MediaBrowser.Providers.Manager var typeName = item.GetType().Name; var typeOptions = libraryOptions.GetTypeOptions(typeName) ?? new TypeOptions { Type = typeName }; - // In order to avoid duplicates, only download these if there are none already - var backdropLimit = typeOptions.GetLimit(ImageType.Backdrop); - var screenshotLimit = typeOptions.GetLimit(ImageType.Screenshot); + // track library limits, adding buffer to allow lazy replacing of current images + var backdropLimit = typeOptions.GetLimit(ImageType.Backdrop) + oldBackdropImages.Length; + var screenshotLimit = typeOptions.GetLimit(ImageType.Screenshot) + oldScreenshotImages.Length; var downloadedImages = new List<ImageType>(); foreach (var provider in providers) @@ -114,11 +139,22 @@ namespace MediaBrowser.Providers.Manager } } + // only delete existing multi-images if new ones were added + if (oldBackdropImages.Length > 0 && oldBackdropImages.Length < item.GetImages(ImageType.Backdrop).Count()) + { + PruneImages(item, oldBackdropImages); + } + + if (oldScreenshotImages.Length > 0 && oldScreenshotImages.Length < item.GetImages(ImageType.Screenshot).Count()) + { + PruneImages(item, oldScreenshotImages); + } + return result; } /// <summary> - /// Refreshes from provider. + /// Refreshes from a dynamic provider. /// </summary> private async Task RefreshFromProvider( BaseItem item, @@ -153,13 +189,14 @@ namespace MediaBrowser.Providers.Manager if (response.Protocol == MediaProtocol.Http) { _logger.LogDebug("Setting image url into item {0}", item.Id); + var index = item.AllowsMultipleImages(imageType) ? item.GetImages(imageType).Count() : 0; item.SetImage( new ItemImageInfo { Path = response.Path, Type = imageType }, - 0); + index); } else { @@ -234,7 +271,7 @@ namespace MediaBrowser.Providers.Manager } /// <summary> - /// Refreshes from provider. + /// Refreshes from a remote provider. /// </summary> /// <param name="item">The item.</param> /// <param name="provider">The provider.</param> @@ -305,12 +342,12 @@ namespace MediaBrowser.Providers.Manager } minWidth = savedOptions.GetMinWidth(ImageType.Backdrop); - await DownloadBackdrops(item, ImageType.Backdrop, backdropLimit, provider, result, list, minWidth, cancellationToken).ConfigureAwait(false); + await DownloadMultiImages(item, ImageType.Backdrop, refreshOptions, backdropLimit, provider, result, list, minWidth, cancellationToken).ConfigureAwait(false); - if (item is IHasScreenshots hasScreenshots) + if (item is IHasScreenshots) { minWidth = savedOptions.GetMinWidth(ImageType.Screenshot); - await DownloadBackdrops(item, ImageType.Screenshot, screenshotLimit, provider, result, list, minWidth, cancellationToken).ConfigureAwait(false); + await DownloadMultiImages(item, ImageType.Screenshot, refreshOptions, screenshotLimit, provider, result, list, minWidth, cancellationToken).ConfigureAwait(false); } } catch (OperationCanceledException) @@ -329,40 +366,36 @@ namespace MediaBrowser.Providers.Manager return options.IsEnabled(type); } - private void ClearImages(BaseItem item, ImageType type) + private void PruneImages(BaseItem item, ItemImageInfo[] images) { - var deleted = false; - var deletedImages = new List<ItemImageInfo>(); - - foreach (var image in item.GetImages(type)) + for (var i = 0; i < images.Length; i++) { - if (!image.IsLocalFile) - { - deletedImages.Add(image); - continue; - } + var image = images[i]; - try - { - _fileSystem.DeleteFile(image.Path); - deleted = true; - } - catch (FileNotFoundException) + if (image.IsLocalFile) { + try + { + _fileSystem.DeleteFile(image.Path); + } + catch (FileNotFoundException) + { + } } } - item.RemoveImages(deletedImages); - - if (deleted) - { - item.ValidateImages(new DirectoryService(_fileSystem)); - } + item.RemoveImages(images); } + /// <summary> + /// Merges a list of images into the provided item, validating existing images and replacing them or adding new images as necessary. + /// </summary> + /// <param name="item">The <see cref="BaseItem"/> to modify.</param> + /// <param name="images">The new images to place in <c>item</c>.</param> + /// <returns><c>true</c> if changes were made to the item; otherwise <c>false</c>.</returns> public bool MergeImages(BaseItem item, IReadOnlyList<LocalImageInfo> images) { - var changed = false; + var changed = item.ValidateImages(new DirectoryService(_fileSystem)); for (var i = 0; i < _singularImages.Length; i++) { @@ -398,18 +431,6 @@ namespace MediaBrowser.Providers.Manager currentImage.DateModified = newDateModified; } } - else - { - var existing = item.GetImageInfo(type, 0); - if (existing != null) - { - if (existing.IsLocalFile && !File.Exists(existing.Path)) - { - item.RemoveImage(existing); - changed = true; - } - } - } } if (UpdateMultiImages(item, images, ImageType.Backdrop)) @@ -417,13 +438,9 @@ namespace MediaBrowser.Providers.Manager changed = true; } - var hasScreenshots = item as IHasScreenshots; - if (hasScreenshots != null) + if (item is IHasScreenshots && UpdateMultiImages(item, images, ImageType.Screenshot)) { - if (UpdateMultiImages(item, images, ImageType.Screenshot)) - { - changed = true; - } + changed = true; } return changed; @@ -536,7 +553,7 @@ namespace MediaBrowser.Providers.Manager return true; } - if (item is IItemByName && item is not MusicArtist) + if (item is IItemByName and not MusicArtist) { var hasDualAccess = item as IHasDualAccess; if (hasDualAccess == null || hasDualAccess.IsAccessedByName) @@ -569,7 +586,7 @@ namespace MediaBrowser.Providers.Manager newIndex); } - private async Task DownloadBackdrops(BaseItem item, ImageType imageType, int limit, IRemoteImageProvider provider, RefreshResult result, IEnumerable<RemoteImageInfo> images, int minWidth, CancellationToken cancellationToken) + private async Task DownloadMultiImages(BaseItem item, ImageType imageType, ImageRefreshOptions refreshOptions, int limit, IRemoteImageProvider provider, RefreshResult result, IEnumerable<RemoteImageInfo> images, int minWidth, CancellationToken cancellationToken) { foreach (var image in images.Where(i => i.Type == imageType)) { @@ -609,8 +626,8 @@ namespace MediaBrowser.Providers.Manager break; } - // If there's already an image of the same size, skip it - if (response.Content.Headers.ContentLength.HasValue) + // If there's already an image of the same file size, skip it unless doing a full refresh + if (response.Content.Headers.ContentLength.HasValue && !refreshOptions.IsReplacingImage(imageType)) { try { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs index 907f0160d..9ac95f23e 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs @@ -11,5 +11,15 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// Gets or sets a value indicating whether include adult content when searching with TMDb. /// </summary> public bool IncludeAdult { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether tags should be imported for series from TMDb. + /// </summary> + public bool ExcludeTagsSeries { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether tags should be imported for movies from TMDb. + /// </summary> + public bool ExcludeTagsMovies { get; set; } } } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html index 6f42549d7..bce647f2a 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html +++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html @@ -29,20 +29,24 @@ Dashboard.showLoadingMsg(); ApiClient.getPluginConfiguration(PluginConfig.pluginId).then(function (config) { document.querySelector('#includeAdult').checked = config.IncludeAdult; + document.querySelector('#excludeTagsSeries').checked = config.ExcludeTagsSeries; + document.querySelector('#excludeTagsMovies').checked = config.ExcludeTagsMovies; Dashboard.hideLoadingMsg(); }); }); - + document.querySelector('.configForm') .addEventListener('submit', function (e) { Dashboard.showLoadingMsg(); - + ApiClient.getPluginConfiguration(PluginConfig.pluginId).then(function (config) { config.IncludeAdult = document.querySelector('#includeAdult').checked; + config.ExcludeTagsSeries = document.querySelector('#excludeTagsSeries').checked; + config.ExcludeTagsMovies = document.querySelector('#excludeTagsMovies').checked; ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config).then(Dashboard.processPluginConfigurationUpdateResult); }); - + e.preventDefault(); return false; }); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs index 74be4c793..f161a1c12 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs @@ -57,11 +57,17 @@ namespace MediaBrowser.Providers.Plugins.Tmdb await EnsureClientConfigAsync().ConfigureAwait(false); + var extraMethods = MovieMethods.Credits | MovieMethods.Releases | MovieMethods.Images | MovieMethods.Videos; + if (!(Plugin.Instance?.Configuration.ExcludeTagsMovies).GetValueOrDefault()) + { + extraMethods |= MovieMethods.Keywords; + } + movie = await _tmDbClient.GetMovieAsync( tmdbId, TmdbUtils.NormalizeLanguage(language), imageLanguages, - MovieMethods.Credits | MovieMethods.Releases | MovieMethods.Images | MovieMethods.Keywords | MovieMethods.Videos, + extraMethods, cancellationToken).ConfigureAwait(false); if (movie != null) @@ -123,11 +129,17 @@ namespace MediaBrowser.Providers.Plugins.Tmdb await EnsureClientConfigAsync().ConfigureAwait(false); + var extraMethods = TvShowMethods.Credits | TvShowMethods.Images | TvShowMethods.ExternalIds | TvShowMethods.Videos | TvShowMethods.ContentRatings | TvShowMethods.EpisodeGroups; + if (!(Plugin.Instance?.Configuration.ExcludeTagsSeries).GetValueOrDefault()) + { + extraMethods |= TvShowMethods.Keywords; + } + series = await _tmDbClient.GetTvShowAsync( tmdbId, language: TmdbUtils.NormalizeLanguage(language), includeImageLanguage: imageLanguages, - extraMethods: TvShowMethods.Credits | TvShowMethods.Images | TvShowMethods.Keywords | TvShowMethods.ExternalIds | TvShowMethods.Videos | TvShowMethods.ContentRatings | TvShowMethods.EpisodeGroups, + extraMethods: extraMethods, cancellationToken: cancellationToken).ConfigureAwait(false); if (series != null) diff --git a/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs b/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs index 2873f6161..1e7fedb36 100644 --- a/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs @@ -71,9 +71,9 @@ namespace Jellyfin.Naming.Tests.TV [InlineData("Season 1/seriesname 05.mkv", 5)] // no hyphen between series name and episode number [InlineData("[BBT-RMX] Ranma ½ - 154 [50AC421A].mkv", 154)] // hyphens in the pre-name info, triple digit episode number [InlineData("Season 2/Episode 21 - 94 Meetings.mp4", 21)] // Title starts with a number + [InlineData("/The.Legend.of.Condor.Heroes.2017.V2.web-dl.1080p.h264.aac-hdctv/The.Legend.of.Condor.Heroes.2017.E07.V2.web-dl.1080p.h264.aac-hdctv.mkv", 7)] // [InlineData("Case Closed (1996-2007)/Case Closed - 317.mkv", 317)] // triple digit episode number // TODO: [InlineData("Season 2/16 12 Some Title.avi", 16)] - // TODO: [InlineData("/The.Legend.of.Condor.Heroes.2017.V2.web-dl.1080p.h264.aac-hdctv/The.Legend.of.Condor.Heroes.2017.E07.V2.web-dl.1080p.h264.aac-hdctv.mkv", 7)] // TODO: [InlineData("Season 4/Uchuu.Senkan.Yamato.2199.E03.avi", 3)] // TODO: [InlineData("Season 2/7 12 Angry Men.avi", 7)] // TODO: [InlineData("Season 02/02x03x04x15 - Ep Name.mp4", 2)] diff --git a/tests/Jellyfin.Naming.Tests/TV/SeriesPathParserTest.cs b/tests/Jellyfin.Naming.Tests/TV/SeriesPathParserTest.cs new file mode 100644 index 000000000..ceb5f8b73 --- /dev/null +++ b/tests/Jellyfin.Naming.Tests/TV/SeriesPathParserTest.cs @@ -0,0 +1,28 @@ +using Emby.Naming.Common; +using Emby.Naming.TV; +using Xunit; + +namespace Jellyfin.Naming.Tests.TV +{ + public class SeriesPathParserTest + { + [Theory] + [InlineData("The.Show.S01", "The.Show")] + [InlineData("/The.Show.S01", "The.Show")] + [InlineData("/some/place/The.Show.S01", "The.Show")] + [InlineData("/something/The.Show.S01", "The.Show")] + [InlineData("The Show Season 10", "The Show")] + [InlineData("The Show S01E01", "The Show")] + [InlineData("The Show S01E01 Episode", "The Show")] + [InlineData("/something/The Show/Season 1", "The Show")] + [InlineData("/something/The Show/S01", "The Show")] + public void SeriesPathParserParseTest(string path, string name) + { + NamingOptions o = new NamingOptions(); + var res = SeriesPathParser.Parse(o, path); + + Assert.Equal(name, res.SeriesName); + Assert.True(res.Success); + } + } +} diff --git a/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs b/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs new file mode 100644 index 000000000..97f4b4058 --- /dev/null +++ b/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs @@ -0,0 +1,28 @@ +using Emby.Naming.Common; +using Emby.Naming.TV; +using Xunit; + +namespace Jellyfin.Naming.Tests.TV +{ + public class SeriesResolverTests + { + [Theory] + [InlineData("The.Show.S01", "The Show")] + [InlineData("The.Show.S01.COMPLETE", "The Show")] + [InlineData("S.H.O.W.S01", "S.H.O.W")] + [InlineData("The.Show.P.I.S01", "The Show P.I")] + [InlineData("The_Show_Season_1", "The Show")] + [InlineData("/something/The_Show/Season 10", "The Show")] + [InlineData("The Show", "The Show")] + [InlineData("/some/path/The Show", "The Show")] + [InlineData("/some/path/The Show s02e10 720p hdtv", "The Show")] + [InlineData("/some/path/The Show s02e10 the episode 720p hdtv", "The Show")] + public void SeriesResolverResolveTest(string path, string name) + { + NamingOptions o = new NamingOptions(); + var res = SeriesResolver.Resolve(o, path); + + Assert.Equal(name, res.Name); + } + } +} diff --git a/tests/Jellyfin.Providers.Tests/Jellyfin.Providers.Tests.csproj b/tests/Jellyfin.Providers.Tests/Jellyfin.Providers.Tests.csproj index bb88ec6a1..10767ae23 100644 --- a/tests/Jellyfin.Providers.Tests/Jellyfin.Providers.Tests.csproj +++ b/tests/Jellyfin.Providers.Tests/Jellyfin.Providers.Tests.csproj @@ -7,6 +7,12 @@ </PropertyGroup> <ItemGroup> + <None Include="Test Data\**\*.*"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </None> + </ItemGroup> + + <ItemGroup> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" /> <PackageReference Include="Moq" Version="4.16.1" /> <PackageReference Include="xunit" Version="2.4.1" /> diff --git a/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs new file mode 100644 index 000000000..6d65ba2d7 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs @@ -0,0 +1,606 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Drawing; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Model.Providers; +using MediaBrowser.Providers.Manager; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Providers.Tests.Manager +{ + public class ItemImageProviderTests + { + private static readonly string TestDataImagePath = "Test Data/Images/blank{0}.jpg"; + + [Fact] + public void ValidateImages_PhotoEmptyProviders_NoChange() + { + var itemImageProvider = GetItemImageProvider(null, null); + var changed = itemImageProvider.ValidateImages(new Photo(), new List<ILocalImageProvider>(), null); + + Assert.False(changed); + } + + [Fact] + public void ValidateImages_EmptyItemEmptyProviders_NoChange() + { + var itemImageProvider = GetItemImageProvider(null, null); + var changed = itemImageProvider.ValidateImages(new MovieWithScreenshots(), new List<ILocalImageProvider>(), null); + + Assert.False(changed); + } + + private static TheoryData<ImageType, int> GetImageTypesWithCount() + { + var theoryTypes = new TheoryData<ImageType, int> + { + // minimal test cases that hit different handling + { ImageType.Primary, 1 }, + { ImageType.Backdrop, 1 }, + { ImageType.Backdrop, 2 }, + { ImageType.Screenshot, 1 } + }; + + return theoryTypes; + } + + [Theory] + [MemberData(nameof(GetImageTypesWithCount))] + public void ValidateImages_EmptyItemAndPopulatedProviders_AddsImages(ImageType imageType, int imageCount) + { + // Has to exist for querying DateModified time on file, results stored but not checked so not populating + BaseItem.FileSystem = Mock.Of<IFileSystem>(); + + var item = new MovieWithScreenshots(); + var imageProvider = GetImageProvider(imageType, imageCount, true); + + var itemImageProvider = GetItemImageProvider(null, null); + var changed = itemImageProvider.ValidateImages(item, new List<ILocalImageProvider> { imageProvider }, null); + + Assert.True(changed); + Assert.Equal(imageCount, item.GetImages(imageType).Count()); + } + + [Theory] + [MemberData(nameof(GetImageTypesWithCount))] + public void ValidateImages_PopulatedItemWithGoodPathsAndEmptyProviders_NoChange(ImageType imageType, int imageCount) + { + var item = GetItemWithImages(imageType, imageCount, true); + + var itemImageProvider = GetItemImageProvider(null, null); + var changed = itemImageProvider.ValidateImages(item, new List<ILocalImageProvider>(), null); + + Assert.False(changed); + Assert.Equal(imageCount, item.GetImages(imageType).Count()); + } + + [Theory] + [MemberData(nameof(GetImageTypesWithCount))] + public void ValidateImages_PopulatedItemWithBadPathsAndEmptyProviders_RemovesImage(ImageType imageType, int imageCount) + { + var item = GetItemWithImages(imageType, imageCount, false); + + var itemImageProvider = GetItemImageProvider(null, null); + var changed = itemImageProvider.ValidateImages(item, new List<ILocalImageProvider>(), null); + + Assert.True(changed); + Assert.Empty(item.GetImages(imageType)); + } + + [Fact] + public void MergeImages_EmptyItemNewImagesEmpty_NoChange() + { + var itemImageProvider = GetItemImageProvider(null, null); + var changed = itemImageProvider.MergeImages(new MovieWithScreenshots(), new List<LocalImageInfo>()); + + Assert.False(changed); + } + + [Theory] + [MemberData(nameof(GetImageTypesWithCount))] + public void MergeImages_PopulatedItemWithGoodPathsAndPopulatedNewImages_AddsUpdatesImages(ImageType imageType, int imageCount) + { + // valid and not valid paths - should replace the valid paths with the invalid ones + var item = GetItemWithImages(imageType, imageCount, true); + var images = GetImages(imageType, imageCount, false); + + var itemImageProvider = GetItemImageProvider(null, null); + var changed = itemImageProvider.MergeImages(item, images); + + Assert.True(changed); + // adds for types that allow multiple, replaces singular type images + if (item.AllowsMultipleImages(imageType)) + { + Assert.Equal(imageCount * 2, item.GetImages(imageType).Count()); + } + else + { + Assert.Single(item.GetImages(imageType)); + Assert.Same(images[0].FileInfo.FullName, item.GetImages(imageType).First().Path); + } + } + + [Theory] + [MemberData(nameof(GetImageTypesWithCount))] + public void MergeImages_PopulatedItemWithGoodPathsAndSameNewImages_NoChange(ImageType imageType, int imageCount) + { + var oldTime = new DateTime(1970, 1, 1); + + // match update time with time added to item images (unix epoch) + var fileSystem = new Mock<IFileSystem>(); + fileSystem.Setup(fs => fs.GetLastWriteTimeUtc(It.IsAny<FileSystemMetadata>())) + .Returns(oldTime); + BaseItem.FileSystem = fileSystem.Object; + + // all valid paths - matching for strictly updating + var item = GetItemWithImages(imageType, imageCount, true); + // set size to non-zero to allow for updates to occur + foreach (var image in item.GetImages(imageType)) + { + image.DateModified = oldTime; + image.Height = 1; + image.Width = 1; + } + + var images = GetImages(imageType, imageCount, true); + + var itemImageProvider = GetItemImageProvider(null, fileSystem); + var changed = itemImageProvider.MergeImages(item, images); + + Assert.False(changed); + } + + [Theory] + [MemberData(nameof(GetImageTypesWithCount))] + public void MergeImages_PopulatedItemWithGoodPathsAndSameNewImagesWithNewTimestamps_ResetsImageSizes(ImageType imageType, int imageCount) + { + var oldTime = new DateTime(1970, 1, 1); + var updatedTime = new DateTime(2021, 1, 1); + + var fileSystem = new Mock<IFileSystem>(); + fileSystem.Setup(fs => fs.GetLastWriteTimeUtc(It.IsAny<FileSystemMetadata>())) + .Returns(updatedTime); + BaseItem.FileSystem = fileSystem.Object; + + // all valid paths - matching for strictly updating + var item = GetItemWithImages(imageType, imageCount, true); + // set size to non-zero to allow for image size reset to occur + foreach (var image in item.GetImages(imageType)) + { + image.DateModified = oldTime; + image.Height = 1; + image.Width = 1; + } + + var images = GetImages(imageType, imageCount, true); + + var itemImageProvider = GetItemImageProvider(null, fileSystem); + var changed = itemImageProvider.MergeImages(item, images); + + Assert.True(changed); + // before and after paths are the same, verify updated by size reset to 0 + Assert.Equal(imageCount, item.GetImages(imageType).Count()); + foreach (var image in item.GetImages(imageType)) + { + Assert.Equal(updatedTime, image.DateModified); + Assert.Equal(0, image.Height); + Assert.Equal(0, image.Width); + } + } + + [Theory] + [InlineData(ImageType.Primary, 1, false)] + [InlineData(ImageType.Backdrop, 2, false)] + [InlineData(ImageType.Screenshot, 2, false)] + [InlineData(ImageType.Primary, 1, true)] + [InlineData(ImageType.Backdrop, 2, true)] + [InlineData(ImageType.Screenshot, 2, true)] + public async void RefreshImages_PopulatedItemPopulatedProviderDynamic_UpdatesImagesIfForced(ImageType imageType, int imageCount, bool forceRefresh) + { + var item = GetItemWithImages(imageType, imageCount, false); + + var libraryOptions = GetLibraryOptions(item, imageType, imageCount); + + var imageResponse = new DynamicImageResponse + { + HasImage = true, + Format = ImageFormat.Jpg, + Path = "url path", + Protocol = MediaProtocol.Http + }; + + var dynamicProvider = new Mock<IDynamicImageProvider>(MockBehavior.Strict); + dynamicProvider.Setup(rp => rp.Name).Returns("MockDynamicProvider"); + dynamicProvider.Setup(rp => rp.GetSupportedImages(item)) + .Returns(new[] { imageType }); + dynamicProvider.Setup(rp => rp.GetImage(item, imageType, It.IsAny<CancellationToken>())) + .ReturnsAsync(imageResponse); + + var refreshOptions = forceRefresh + ? new ImageRefreshOptions(null) + { + ImageRefreshMode = MetadataRefreshMode.FullRefresh, ReplaceAllImages = true + } + : new ImageRefreshOptions(null); + + var itemImageProvider = GetItemImageProvider(null, new Mock<IFileSystem>()); + var result = await itemImageProvider.RefreshImages(item, libraryOptions, new List<IImageProvider> { dynamicProvider.Object }, refreshOptions, CancellationToken.None); + + Assert.Equal(forceRefresh, result.UpdateType.HasFlag(ItemUpdateType.ImageUpdate)); + if (forceRefresh) + { + // replaces multi-types + Assert.Single(item.GetImages(imageType)); + } + else + { + // adds to multi-types if room + Assert.Equal(imageCount, item.GetImages(imageType).Count()); + } + } + + [Theory] + [InlineData(ImageType.Primary, 1, true, MediaProtocol.Http)] + [InlineData(ImageType.Backdrop, 2, true, MediaProtocol.Http)] + [InlineData(ImageType.Primary, 1, true, MediaProtocol.File)] + [InlineData(ImageType.Backdrop, 2, true, MediaProtocol.File)] + [InlineData(ImageType.Primary, 1, false, MediaProtocol.File)] + [InlineData(ImageType.Backdrop, 2, false, MediaProtocol.File)] + public async void RefreshImages_EmptyItemPopulatedProviderDynamic_AddsImages(ImageType imageType, int imageCount, bool responseHasPath, MediaProtocol protocol) + { + // Has to exist for querying DateModified time on file, results stored but not checked so not populating + BaseItem.FileSystem = Mock.Of<IFileSystem>(); + + var item = new MovieWithScreenshots(); + + var libraryOptions = GetLibraryOptions(item, imageType, imageCount); + + // Path must exist if set: is read in as a stream by AsyncFile.OpenRead + var imageResponse = new DynamicImageResponse + { + HasImage = true, + Format = ImageFormat.Jpg, + Path = responseHasPath ? string.Format(CultureInfo.InvariantCulture, TestDataImagePath, 0) : null, + Protocol = protocol + }; + + var dynamicProvider = new Mock<IDynamicImageProvider>(MockBehavior.Strict); + dynamicProvider.Setup(rp => rp.Name).Returns("MockDynamicProvider"); + dynamicProvider.Setup(rp => rp.GetSupportedImages(item)) + .Returns(new[] { imageType }); + dynamicProvider.Setup(rp => rp.GetImage(item, imageType, It.IsAny<CancellationToken>())) + .ReturnsAsync(imageResponse); + + var refreshOptions = new ImageRefreshOptions(null); + + var providerManager = new Mock<IProviderManager>(MockBehavior.Strict); + providerManager.Setup(pm => pm.SaveImage(item, It.IsAny<Stream>(), It.IsAny<string>(), imageType, null, It.IsAny<CancellationToken>())) + .Callback<BaseItem, Stream, string, ImageType, int?, CancellationToken>((callbackItem, _, _, callbackType, _, _) => callbackItem.SetImagePath(callbackType, 0, new FileSystemMetadata())) + .Returns(Task.CompletedTask); + var itemImageProvider = GetItemImageProvider(providerManager.Object, null); + var result = await itemImageProvider.RefreshImages(item, libraryOptions, new List<IImageProvider> { dynamicProvider.Object }, refreshOptions, CancellationToken.None); + + Assert.True(result.UpdateType.HasFlag(ItemUpdateType.ImageUpdate)); + // dynamic provider unable to return multiple images + Assert.Single(item.GetImages(imageType)); + if (protocol == MediaProtocol.Http) + { + Assert.Equal(imageResponse.Path, item.GetImagePath(imageType, 0)); + } + } + + [Theory] + [InlineData(ImageType.Primary, 1, false)] + [InlineData(ImageType.Backdrop, 1, false)] + [InlineData(ImageType.Backdrop, 2, false)] + [InlineData(ImageType.Screenshot, 2, false)] + [InlineData(ImageType.Primary, 1, true)] + [InlineData(ImageType.Backdrop, 1, true)] + [InlineData(ImageType.Backdrop, 2, true)] + [InlineData(ImageType.Screenshot, 2, true)] + public async void RefreshImages_PopulatedItemPopulatedProviderRemote_UpdatesImagesIfForced(ImageType imageType, int imageCount, bool forceRefresh) + { + var item = GetItemWithImages(imageType, imageCount, false); + + var libraryOptions = GetLibraryOptions(item, imageType, imageCount); + + var remoteProvider = new Mock<IRemoteImageProvider>(MockBehavior.Strict); + remoteProvider.Setup(rp => rp.Name).Returns("MockRemoteProvider"); + remoteProvider.Setup(rp => rp.GetSupportedImages(item)) + .Returns(new[] { imageType }); + + var refreshOptions = forceRefresh + ? new ImageRefreshOptions(null) + { + ImageRefreshMode = MetadataRefreshMode.FullRefresh, ReplaceAllImages = true + } + : new ImageRefreshOptions(null); + + var remoteInfo = new List<RemoteImageInfo>(); + for (int i = 0; i < imageCount; i++) + { + remoteInfo.Add(new RemoteImageInfo + { + Type = imageType, + Url = "image url " + i, + Width = 1 // min width is set to 0, this will always pass + }); + } + + var providerManager = new Mock<IProviderManager>(MockBehavior.Strict); + providerManager.Setup(pm => pm.GetAvailableRemoteImages(It.IsAny<BaseItem>(), It.IsAny<RemoteImageQuery>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(remoteInfo); + var itemImageProvider = GetItemImageProvider(providerManager.Object, new Mock<IFileSystem>()); + var result = await itemImageProvider.RefreshImages(item, libraryOptions, new List<IImageProvider> { remoteProvider.Object }, refreshOptions, CancellationToken.None); + + Assert.Equal(forceRefresh, result.UpdateType.HasFlag(ItemUpdateType.ImageUpdate)); + Assert.Equal(imageCount, item.GetImages(imageType).Count()); + foreach (var image in item.GetImages(imageType)) + { + if (forceRefresh) + { + Assert.Matches(@"image url [0-9]", image.Path); + } + else + { + Assert.DoesNotMatch(@"image url [0-9]", image.Path); + } + } + } + + [Theory] + [InlineData(ImageType.Primary, 0, false)] // singular type only fetches if type is missing from item, no caching + [InlineData(ImageType.Backdrop, 0, false)] // empty item, no cache to check + [InlineData(ImageType.Backdrop, 1, false)] // populated item, cached so no download + [InlineData(ImageType.Backdrop, 1, true)] // populated item, forced to download + public async void RefreshImages_NonStubItemPopulatedProviderRemote_DownloadsIfNecessary(ImageType imageType, int initialImageCount, bool fullRefresh) + { + var targetImageCount = 1; + + // Set path and media source manager so images will be downloaded (EnableImageStub will return false) + var item = GetItemWithImages(imageType, initialImageCount, false); + item.Path = "non-empty path"; + BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>(); + + // seek 2 so it won't short-circuit out of downloading when populated + var libraryOptions = GetLibraryOptions(item, imageType, 2); + + var content = "Content"; + var remoteProvider = new Mock<IRemoteImageProvider>(MockBehavior.Strict); + remoteProvider.Setup(rp => rp.Name).Returns("MockRemoteProvider"); + remoteProvider.Setup(rp => rp.GetSupportedImages(item)) + .Returns(new[] { imageType }); + remoteProvider.Setup(rp => rp.GetImageResponse(It.IsAny<string>(), It.IsAny<CancellationToken>())) + .ReturnsAsync((string url, CancellationToken _) => new HttpResponseMessage + { + ReasonPhrase = url, + StatusCode = HttpStatusCode.OK, + Content = new StringContent(content, Encoding.UTF8, "image/jpeg") + }); + + var refreshOptions = fullRefresh + ? new ImageRefreshOptions(null) + { + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllImages = true + } + : new ImageRefreshOptions(null); + + var remoteInfo = new List<RemoteImageInfo>(); + for (int i = 0; i < targetImageCount; i++) + { + remoteInfo.Add(new RemoteImageInfo + { + Type = imageType, + Url = "image url " + i, + Width = 1 // min width is set to 0, this will always pass + }); + } + + var providerManager = new Mock<IProviderManager>(MockBehavior.Strict); + providerManager.Setup(pm => pm.GetAvailableRemoteImages(It.IsAny<BaseItem>(), It.IsAny<RemoteImageQuery>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(remoteInfo); + providerManager.Setup(pm => pm.SaveImage(item, It.IsAny<Stream>(), It.IsAny<string>(), imageType, null, It.IsAny<CancellationToken>())) + .Callback<BaseItem, Stream, string, ImageType, int?, CancellationToken>((callbackItem, _, _, callbackType, _, _) => + callbackItem.SetImagePath(callbackType, callbackItem.AllowsMultipleImages(callbackType) ? callbackItem.GetImages(callbackType).Count() : 0, new FileSystemMetadata())) + .Returns(Task.CompletedTask); + var fileSystem = new Mock<IFileSystem>(); + // match reported file size to image content length - condition for skipping already downloaded multi-images + fileSystem.Setup(fs => fs.GetFileInfo(It.IsAny<string>())) + .Returns(new FileSystemMetadata { Length = content.Length }); + var itemImageProvider = GetItemImageProvider(providerManager.Object, fileSystem); + var result = await itemImageProvider.RefreshImages(item, libraryOptions, new List<IImageProvider> { remoteProvider.Object }, refreshOptions, CancellationToken.None); + + Assert.Equal(initialImageCount == 0 || fullRefresh, result.UpdateType.HasFlag(ItemUpdateType.ImageUpdate)); + Assert.Equal(targetImageCount, item.GetImages(imageType).Count()); + } + + [Theory] + [MemberData(nameof(GetImageTypesWithCount))] + public async void RefreshImages_EmptyItemPopulatedProviderRemoteExtras_LimitsImages(ImageType imageType, int imageCount) + { + var item = new MovieWithScreenshots(); + + var libraryOptions = GetLibraryOptions(item, imageType, imageCount); + + var remoteProvider = new Mock<IRemoteImageProvider>(MockBehavior.Strict); + remoteProvider.Setup(rp => rp.Name).Returns("MockRemoteProvider"); + remoteProvider.Setup(rp => rp.GetSupportedImages(item)) + .Returns(new[] { imageType }); + + var refreshOptions = new ImageRefreshOptions(null); + + // populate remote with double the required images to verify count is trimmed to the library option count + var remoteInfo = new List<RemoteImageInfo>(); + for (int i = 0; i < imageCount * 2; i++) + { + remoteInfo.Add(new RemoteImageInfo + { + Type = imageType, + Url = "image url " + i, + Width = 1 // min width is set to 0, this will always pass + }); + } + + var providerManager = new Mock<IProviderManager>(MockBehavior.Strict); + providerManager.Setup(pm => pm.GetAvailableRemoteImages(It.IsAny<BaseItem>(), It.IsAny<RemoteImageQuery>(), It.IsAny<CancellationToken>())) + .ReturnsAsync(remoteInfo); + var itemImageProvider = GetItemImageProvider(providerManager.Object, null); + var result = await itemImageProvider.RefreshImages(item, libraryOptions, new List<IImageProvider> { remoteProvider.Object }, refreshOptions, CancellationToken.None); + + Assert.True(result.UpdateType.HasFlag(ItemUpdateType.ImageUpdate)); + var actualImages = item.GetImages(imageType).ToList(); + Assert.Equal(imageCount, actualImages.Count); + // images from the provider manager are sorted by preference (earlier images are higher priority) so we can verify that low url numbers are chosen + foreach (var image in actualImages) + { + var index = int.Parse(Regex.Match(image.Path, @"[0-9]+").Value, NumberStyles.Integer, CultureInfo.InvariantCulture); + Assert.True(index < imageCount); + } + } + + [Theory] + [MemberData(nameof(GetImageTypesWithCount))] + public async void RefreshImages_PopulatedItemEmptyProviderRemoteFullRefresh_DoesntClearImages(ImageType imageType, int imageCount) + { + var item = GetItemWithImages(imageType, imageCount, false); + + var libraryOptions = GetLibraryOptions(item, imageType, imageCount); + + var remoteProvider = new Mock<IRemoteImageProvider>(MockBehavior.Strict); + remoteProvider.Setup(rp => rp.Name).Returns("MockRemoteProvider"); + remoteProvider.Setup(rp => rp.GetSupportedImages(item)) + .Returns(new[] { imageType }); + + var refreshOptions = new ImageRefreshOptions(null) + { + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllImages = true + }; + + var itemImageProvider = GetItemImageProvider(Mock.Of<IProviderManager>(), null); + var result = await itemImageProvider.RefreshImages(item, libraryOptions, new List<IImageProvider> { remoteProvider.Object }, refreshOptions, CancellationToken.None); + + Assert.False(result.UpdateType.HasFlag(ItemUpdateType.ImageUpdate)); + Assert.Equal(imageCount, item.GetImages(imageType).Count()); + } + + private static ItemImageProvider GetItemImageProvider(IProviderManager? providerManager, Mock<IFileSystem>? mockFileSystem) + { + // strict to ensure this isn't accidentally used where a prepared mock is intended + providerManager ??= Mock.Of<IProviderManager>(MockBehavior.Strict); + + // BaseItem.ValidateImages depends on the directory service being able to list directory contents, give it the expected valid file paths + mockFileSystem ??= new Mock<IFileSystem>(MockBehavior.Strict); + mockFileSystem.Setup(fs => fs.GetFilePaths(It.IsAny<string>(), It.IsAny<bool>())) + .Returns(new[] + { + string.Format(CultureInfo.InvariantCulture, TestDataImagePath, 0), + string.Format(CultureInfo.InvariantCulture, TestDataImagePath, 1) + }); + + return new ItemImageProvider(new NullLogger<ItemImageProvider>(), providerManager, mockFileSystem.Object); + } + + private static BaseItem GetItemWithImages(ImageType type, int count, bool validPaths) + { + // Has to exist for querying DateModified time on file, results stored but not checked so not populating + BaseItem.FileSystem ??= Mock.Of<IFileSystem>(); + + var item = new MovieWithScreenshots(); + + var path = validPaths ? TestDataImagePath : "invalid path {0}"; + for (int i = 0; i < count; i++) + { + item.SetImagePath(type, i, new FileSystemMetadata + { + FullName = string.Format(CultureInfo.InvariantCulture, path, i), + }); + } + + return item; + } + + private static ILocalImageProvider GetImageProvider(ImageType type, int count, bool validPaths) + { + var images = GetImages(type, count, validPaths); + + var imageProvider = new Mock<ILocalImageProvider>(); + imageProvider.Setup(ip => ip.GetImages(It.IsAny<BaseItem>(), It.IsAny<IDirectoryService>())) + .Returns(images); + return imageProvider.Object; + } + + /// <summary> + /// Creates a list of <see cref="LocalImageInfo"/> references of the specified type and size, optionally pointing to files that exist. + /// </summary> + private static List<LocalImageInfo> GetImages(ImageType type, int count, bool validPaths) + { + var path = validPaths ? TestDataImagePath : "invalid path {0}"; + var images = new List<LocalImageInfo>(count); + for (int i = 0; i < count; i++) + { + images.Add(new LocalImageInfo + { + Type = type, + FileInfo = new FileSystemMetadata + { + FullName = string.Format(CultureInfo.InvariantCulture, path, i) + } + }); + } + + return images; + } + + /// <summary> + /// Generates a <see cref="LibraryOptions"/> object that will allow for the requested number of images for the target type. + /// </summary> + private static LibraryOptions GetLibraryOptions(BaseItem item, ImageType type, int count) + { + return new LibraryOptions + { + TypeOptions = new[] + { + new TypeOptions + { + Type = item.GetType().Name, + ImageOptions = new[] + { + new ImageOption + { + Type = type, + Limit = count, + MinWidth = 0 + } + } + } + } + }; + } + + // Create a class that implements IHasScreenshots for testing since no BaseItem class is also IHasScreenshots + private class MovieWithScreenshots : Movie, IHasScreenshots + { + // No contents + } + } +} diff --git a/tests/Jellyfin.Providers.Tests/Test Data/Images/blank0.jpg b/tests/Jellyfin.Providers.Tests/Test Data/Images/blank0.jpg new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Test Data/Images/blank0.jpg diff --git a/tests/Jellyfin.Providers.Tests/Test Data/Images/blank1.jpg b/tests/Jellyfin.Providers.Tests/Test Data/Images/blank1.jpg new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Test Data/Images/blank1.jpg diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs index 143020d43..3e7d6ed1d 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs @@ -40,7 +40,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization await localizationManager.LoadAll(); var cultures = localizationManager.GetCultures().ToList(); - Assert.Equal(189, cultures.Count); + Assert.Equal(190, cultures.Count); var germany = cultures.FirstOrDefault(x => x.TwoLetterISOLanguageName.Equals("de", StringComparison.Ordinal)); Assert.NotNull(germany); diff --git a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs index 976e19d46..3d34a18e7 100644 --- a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs +++ b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs @@ -3,7 +3,6 @@ using System.Collections.Concurrent; using System.IO; using System.Threading; using Emby.Server.Implementations; -using Emby.Server.Implementations.IO; using MediaBrowser.Common; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; @@ -67,7 +66,7 @@ namespace Jellyfin.Server.Integration.Tests var startupConfig = Program.CreateAppConfiguration(commandLineOpts, appPaths); ILoggerFactory loggerFactory = new SerilogLoggerFactory(); - var serviceCollection = new ServiceCollection(); + _disposableComponents.Add(loggerFactory); // Create the app host and initialize it @@ -75,11 +74,10 @@ namespace Jellyfin.Server.Integration.Tests appPaths, loggerFactory, commandLineOpts, - new ConfigurationBuilder().Build(), - new ManagedFileSystem(loggerFactory.CreateLogger<ManagedFileSystem>(), appPaths), - serviceCollection); + new ConfigurationBuilder().Build()); _disposableComponents.Add(appHost); - appHost.Init(); + var serviceCollection = new ServiceCollection(); + appHost.Init(serviceCollection); // Configure the web host builder Program.ConfigureWebHostBuilder(builder, appHost, serviceCollection, commandLineOpts, startupConfig, appPaths); diff --git a/tests/Jellyfin.Server.Integration.Tests/TestAppHost.cs b/tests/Jellyfin.Server.Integration.Tests/TestAppHost.cs index 0a463cfa3..bf74efa09 100644 --- a/tests/Jellyfin.Server.Integration.Tests/TestAppHost.cs +++ b/tests/Jellyfin.Server.Integration.Tests/TestAppHost.cs @@ -2,9 +2,7 @@ using System.Collections.Generic; using System.Reflection; using Emby.Server.Implementations; using MediaBrowser.Controller; -using MediaBrowser.Model.IO; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Jellyfin.Server.Integration.Tests @@ -21,22 +19,16 @@ namespace Jellyfin.Server.Integration.Tests /// <param name="loggerFactory">The <see cref="ILoggerFactory" /> to be used by the <see cref="CoreAppHost" />.</param> /// <param name="options">The <see cref="StartupOptions" /> to be used by the <see cref="CoreAppHost" />.</param> /// <param name="startup">The <see cref="IConfiguration" /> to be used by the <see cref="CoreAppHost" />.</param> - /// <param name="fileSystem">The <see cref="IFileSystem" /> to be used by the <see cref="CoreAppHost" />.</param> - /// <param name="collection">The <see cref="IServiceCollection"/> to be used by the <see cref="CoreAppHost"/>.</param> public TestAppHost( IServerApplicationPaths applicationPaths, ILoggerFactory loggerFactory, IStartupOptions options, - IConfiguration startup, - IFileSystem fileSystem, - IServiceCollection collection) + IConfiguration startup) : base( applicationPaths, loggerFactory, options, - startup, - fileSystem, - collection) + startup) { } |
