aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.config/dotnet-tools.json2
-rw-r--r--.github/workflows/ci-codeql-analysis.yml8
-rw-r--r--.github/workflows/ci-openapi.yml4
-rw-r--r--.github/workflows/ci-tests.yml4
-rw-r--r--.github/workflows/commands.yml6
-rw-r--r--.github/workflows/issue-template-check.yml2
-rw-r--r--.github/workflows/pull-request-conflict.yml2
-rw-r--r--.github/workflows/release-bump-version.yaml4
-rw-r--r--Directory.Packages.props34
-rw-r--r--Emby.Server.Implementations/ConfigurationOptions.cs3
-rw-r--r--Emby.Server.Implementations/Data/BaseSqliteRepository.cs92
-rw-r--r--Emby.Server.Implementations/Data/ManagedConnection.cs62
-rw-r--r--Emby.Server.Implementations/Data/SqliteItemRepository.cs69
-rw-r--r--Emby.Server.Implementations/Data/SqliteUserDataRepository.cs8
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs8
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs8
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs30
-rw-r--r--Emby.Server.Implementations/Localization/Core/da.json12
-rw-r--r--Emby.Server.Implementations/Localization/Core/de.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/es-MX.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/es.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/es_419.json8
-rw-r--r--Emby.Server.Implementations/Localization/Core/es_DO.json110
-rw-r--r--Emby.Server.Implementations/Localization/Core/ga.json15
-rw-r--r--Emby.Server.Implementations/Localization/Core/he.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/hu.json12
-rw-r--r--Emby.Server.Implementations/Localization/Core/id.json8
-rw-r--r--Emby.Server.Implementations/Localization/Core/it.json54
-rw-r--r--Emby.Server.Implementations/Localization/Core/lv.json16
-rw-r--r--Emby.Server.Implementations/Localization/Core/ml.json8
-rw-r--r--Emby.Server.Implementations/Localization/Core/nb.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/ro.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/sv.json5
-rw-r--r--Emby.Server.Implementations/Localization/Core/uz.json17
-rw-r--r--Emby.Server.Implementations/Localization/Core/vi.json8
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-TW.json4
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs18
-rw-r--r--Emby.Server.Implementations/Session/SessionManager.cs3
-rw-r--r--Emby.Server.Implementations/TV/TVSeriesManager.cs2
-rw-r--r--Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandler.cs18
-rw-r--r--Jellyfin.Api/Controllers/ItemUpdateController.cs52
-rw-r--r--Jellyfin.Api/Controllers/ItemsController.cs4
-rw-r--r--Jellyfin.Api/Controllers/LibraryStructureController.cs18
-rw-r--r--Jellyfin.Api/Controllers/TrailersController.cs1
-rw-r--r--Jellyfin.Api/Controllers/TvShowsController.cs7
-rw-r--r--Jellyfin.Api/Helpers/MediaInfoHelper.cs13
-rw-r--r--Jellyfin.Api/Helpers/StreamingHelpers.cs14
-rw-r--r--Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs25
-rw-r--r--Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs2
-rw-r--r--Jellyfin.Server/Migrations/MigrationRunner.cs3
-rw-r--r--Jellyfin.Server/Migrations/Routines/FixAudioData.cs106
-rw-r--r--Jellyfin.Server/Startup.cs7
-rw-r--r--MediaBrowser.Controller/Entities/BaseItem.cs3
-rw-r--r--MediaBrowser.Controller/Entities/Folder.cs3
-rw-r--r--MediaBrowser.Controller/Entities/InternalItemsQuery.cs3
-rw-r--r--MediaBrowser.Controller/Entities/Movies/Movie.cs3
-rw-r--r--MediaBrowser.Controller/Entities/TV/Season.cs19
-rw-r--r--MediaBrowser.Controller/Entities/TV/Series.cs53
-rw-r--r--MediaBrowser.Controller/Entities/Trailer.cs3
-rw-r--r--MediaBrowser.Controller/Extensions/ConfigurationExtensions.cs15
-rw-r--r--MediaBrowser.Controller/Library/ILibraryManager.cs8
-rw-r--r--MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs11
-rw-r--r--MediaBrowser.Controller/Providers/DirectoryService.cs16
-rw-r--r--MediaBrowser.Controller/Providers/IDirectoryService.cs2
-rw-r--r--MediaBrowser.Controller/Providers/IProviderManager.cs8
-rw-r--r--MediaBrowser.Controller/Providers/ItemInfo.cs6
-rw-r--r--MediaBrowser.LocalMetadata/Images/EpisodeLocalImageProvider.cs17
-rw-r--r--MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs27
-rw-r--r--MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs4
-rw-r--r--MediaBrowser.Model/Entities/VirtualFolderInfo.cs2
-rw-r--r--MediaBrowser.Providers/Manager/ImageSaver.cs66
-rw-r--r--MediaBrowser.Providers/Manager/ItemImageProvider.cs14
-rw-r--r--MediaBrowser.Providers/Manager/MetadataService.cs258
-rw-r--r--MediaBrowser.Providers/Manager/ProviderManager.cs6
-rw-r--r--MediaBrowser.Providers/MediaInfo/AudioFileProber.cs307
-rw-r--r--MediaBrowser.Providers/Movies/MovieMetadataService.cs16
-rw-r--r--MediaBrowser.Providers/Movies/TrailerMetadataService.cs21
-rw-r--r--MediaBrowser.Providers/Music/AlbumMetadataService.cs4
-rw-r--r--MediaBrowser.Providers/Music/AudioMetadataService.cs5
-rw-r--r--MediaBrowser.Providers/Music/MusicVideoMetadataService.cs5
-rw-r--r--MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs21
-rw-r--r--MediaBrowser.Providers/TV/SeriesMetadataService.cs63
-rw-r--r--MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs15
-rw-r--r--MediaBrowser.XbmcMetadata/Parsers/SeriesNfoSeasonParser.cs60
-rw-r--r--MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs5
-rw-r--r--MediaBrowser.XbmcMetadata/Providers/SeriesNfoSeasonProvider.cs89
-rw-r--r--MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs2
-rw-r--r--MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs17
-rw-r--r--README.md8
-rw-r--r--src/Jellyfin.Extensions/StreamExtensions.cs7
-rw-r--r--tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandlerTests.cs58
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeExternalSourcesTests.cs4
-rw-r--r--tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs24
-rw-r--r--tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs5
-rw-r--r--tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs2
-rw-r--r--tests/Jellyfin.Providers.Tests/MediaInfo/EmbeddedImageProviderTests.cs6
-rw-r--r--tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs6
-rw-r--r--tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs2
-rw-r--r--tests/Jellyfin.Providers.Tests/MediaInfo/VideoImageProviderTests.cs6
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs114
-rw-r--r--tests/Jellyfin.XbmcMetadata.Tests/Location/MovieNfoLocationTests.cs5
101 files changed, 1599 insertions, 761 deletions
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index 440d52904..4bb3f8cae 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"dotnet-ef": {
- "version": "8.0.5",
+ "version": "8.0.6",
"commands": [
"dotnet-ef"
]
diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml
index 0ccc3307c..7891f7f5a 100644
--- a/.github/workflows/ci-codeql-analysis.yml
+++ b/.github/workflows/ci-codeql-analysis.yml
@@ -20,18 +20,18 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
+ uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Setup .NET
uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0
with:
dotnet-version: '8.0.x'
- name: Initialize CodeQL
- uses: github/codeql-action/init@9fdb3e49720b44c48891d036bb502feb25684276 # v3.25.6
+ uses: github/codeql-action/init@23acc5c183826b7a8a97bce3cecc52db901f8251 # v3.25.10
with:
languages: ${{ matrix.language }}
queries: +security-extended
- name: Autobuild
- uses: github/codeql-action/autobuild@9fdb3e49720b44c48891d036bb502feb25684276 # v3.25.6
+ uses: github/codeql-action/autobuild@23acc5c183826b7a8a97bce3cecc52db901f8251 # v3.25.10
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@9fdb3e49720b44c48891d036bb502feb25684276 # v3.25.6
+ uses: github/codeql-action/analyze@23acc5c183826b7a8a97bce3cecc52db901f8251 # v3.25.10
diff --git a/.github/workflows/ci-openapi.yml b/.github/workflows/ci-openapi.yml
index edfd74f87..c142de9a9 100644
--- a/.github/workflows/ci-openapi.yml
+++ b/.github/workflows/ci-openapi.yml
@@ -16,7 +16,7 @@ jobs:
permissions: read-all
steps:
- name: Checkout repository
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
+ uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -41,7 +41,7 @@ jobs:
permissions: read-all
steps:
- name: Checkout repository
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
+ uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml
index a5650c5a4..1e651068e 100644
--- a/.github/workflows/ci-tests.yml
+++ b/.github/workflows/ci-tests.yml
@@ -19,7 +19,7 @@ jobs:
runs-on: "${{ matrix.os }}"
steps:
- - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
+ - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0
with:
@@ -34,7 +34,7 @@ jobs:
--verbosity minimal
- name: Merge code coverage results
- uses: danielpalme/ReportGenerator-GitHub-Action@9f1033dc04b18a7dfa51aeefeb18540e8939021f # 5.3.4
+ uses: danielpalme/ReportGenerator-GitHub-Action@4924a48df5dbcdfbcbaef0cc1ad7d65d5aade7dd # 5.3.6
with:
reports: "**/coverage.cobertura.xml"
targetdir: "merged/"
diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml
index cbaea507f..ee413bb10 100644
--- a/.github/workflows/commands.yml
+++ b/.github/workflows/commands.yml
@@ -24,7 +24,7 @@ jobs:
reactions: '+1'
- name: Checkout the latest code
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
+ uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
token: ${{ secrets.JF_BOT_TOKEN }}
fetch-depth: 0
@@ -51,7 +51,7 @@ jobs:
reactions: eyes
- name: Checkout the latest code
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
+ uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
token: ${{ secrets.JF_BOT_TOKEN }}
fetch-depth: 0
@@ -128,7 +128,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: pull in script
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
+ uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
repository: jellyfin/jellyfin-triage-script
- name: install python
diff --git a/.github/workflows/issue-template-check.yml b/.github/workflows/issue-template-check.yml
index 4a0b4f883..f73b2c429 100644
--- a/.github/workflows/issue-template-check.yml
+++ b/.github/workflows/issue-template-check.yml
@@ -10,7 +10,7 @@ jobs:
issues: write
steps:
- name: pull in script
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
+ uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
repository: jellyfin/jellyfin-triage-script
- name: install python
diff --git a/.github/workflows/pull-request-conflict.yml b/.github/workflows/pull-request-conflict.yml
index 5823c309c..5d342b7f8 100644
--- a/.github/workflows/pull-request-conflict.yml
+++ b/.github/workflows/pull-request-conflict.yml
@@ -15,7 +15,7 @@ jobs:
if: ${{ github.repository == 'jellyfin/jellyfin' }}
steps:
- name: Apply label
- uses: eps1lon/actions-label-merge-conflict@6d74047dcef155976a15e4a124dde2c7fe0c5522 # v3.0.1
+ uses: eps1lon/actions-label-merge-conflict@1b1b1fcde06a9b3d089f3464c96417961dde1168 # v3.0.2
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request_target'}}
with:
dirtyLabel: 'merge conflict'
diff --git a/.github/workflows/release-bump-version.yaml b/.github/workflows/release-bump-version.yaml
index f471572e5..575f2d756 100644
--- a/.github/workflows/release-bump-version.yaml
+++ b/.github/workflows/release-bump-version.yaml
@@ -33,7 +33,7 @@ jobs:
yq-version: v4.9.8
- name: Checkout Repository
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
+ uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
ref: ${{ env.TAG_BRANCH }}
@@ -66,7 +66,7 @@ jobs:
NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
steps:
- name: Checkout Repository
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
+ uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
ref: ${{ env.TAG_BRANCH }}
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 919b99d82..feb87d85d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -16,23 +16,23 @@
<PackageVersion Include="Diacritics" Version="3.3.29" />
<PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
<PackageVersion Include="DotNet.Glob" Version="3.1.3" />
- <PackageVersion Include="EFCoreSecondLevelCacheInterceptor" Version="4.4.3" />
+ <PackageVersion Include="EFCoreSecondLevelCacheInterceptor" Version="4.5.0" />
<PackageVersion Include="FsCheck.Xunit" Version="2.16.6" />
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="7.3.0.2" />
<PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" />
- <PackageVersion Include="IDisposableAnalyzers" Version="4.0.7" />
+ <PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" />
<PackageVersion Include="Jellyfin.XmlTv" Version="10.8.0" />
<PackageVersion Include="libse" Version="4.0.5" />
<PackageVersion Include="LrcParser" Version="2023.524.0" />
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="6.1.0" />
- <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="8.0.5" />
- <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.5" />
+ <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="8.0.6" />
+ <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.6" />
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4" />
- <PackageVersion Include="Microsoft.Data.Sqlite" Version="8.0.5" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.5" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.5" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.5" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.5" />
+ <PackageVersion Include="Microsoft.Data.Sqlite" Version="8.0.6" />
+ <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.6" />
+ <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.6" />
+ <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.6" />
+ <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.6" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
@@ -41,8 +41,8 @@
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
- <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.5" />
- <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.5" />
+ <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.6" />
+ <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.6" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
@@ -59,10 +59,10 @@
<PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.0" />
<PackageVersion Include="prometheus-net" Version="8.2.1" />
<PackageVersion Include="Serilog.AspNetCore" Version="8.0.1" />
- <PackageVersion Include="Serilog.Enrichers.Thread" Version="3.1.0" />
- <PackageVersion Include="Serilog.Settings.Configuration" Version="8.0.0" />
- <PackageVersion Include="Serilog.Sinks.Async" Version="1.5.0" />
- <PackageVersion Include="Serilog.Sinks.Console" Version="5.0.1" />
+ <PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
+ <PackageVersion Include="Serilog.Settings.Configuration" Version="8.0.1" />
+ <PackageVersion Include="Serilog.Sinks.Async" Version="2.0.0" />
+ <PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageVersion Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
@@ -84,8 +84,8 @@
<PackageVersion Include="TMDbLib" Version="2.2.0" />
<PackageVersion Include="UTF.Unknown" Version="2.5.1" />
<PackageVersion Include="Xunit.Priority" Version="1.1.6" />
- <PackageVersion Include="xunit.runner.visualstudio" Version="2.5.8" />
+ <PackageVersion Include="xunit.runner.visualstudio" Version="2.8.1" />
<PackageVersion Include="Xunit.SkippableFact" Version="1.4.13" />
- <PackageVersion Include="xunit" Version="2.7.1" />
+ <PackageVersion Include="xunit" Version="2.8.1" />
</ItemGroup>
</Project> \ No newline at end of file
diff --git a/Emby.Server.Implementations/ConfigurationOptions.cs b/Emby.Server.Implementations/ConfigurationOptions.cs
index f0c267627..c06cd8510 100644
--- a/Emby.Server.Implementations/ConfigurationOptions.cs
+++ b/Emby.Server.Implementations/ConfigurationOptions.cs
@@ -19,7 +19,8 @@ namespace Emby.Server.Implementations
{ FfmpegAnalyzeDurationKey, "200M" },
{ PlaylistsAllowDuplicatesKey, bool.FalseString },
{ BindToUnixSocketKey, bool.FalseString },
- { SqliteCacheSizeKey, "20000" }
+ { SqliteCacheSizeKey, "20000" },
+ { SqliteDisableSecondLevelCacheKey, bool.FalseString }
};
}
}
diff --git a/Emby.Server.Implementations/Data/BaseSqliteRepository.cs b/Emby.Server.Implementations/Data/BaseSqliteRepository.cs
index b1c99227c..5291999dc 100644
--- a/Emby.Server.Implementations/Data/BaseSqliteRepository.cs
+++ b/Emby.Server.Implementations/Data/BaseSqliteRepository.cs
@@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
+using System.Threading;
using Jellyfin.Extensions;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
@@ -13,6 +14,8 @@ namespace Emby.Server.Implementations.Data
public abstract class BaseSqliteRepository : IDisposable
{
private bool _disposed = false;
+ private SemaphoreSlim _writeLock = new SemaphoreSlim(1, 1);
+ private SqliteConnection _writeConnection;
/// <summary>
/// Initializes a new instance of the <see cref="BaseSqliteRepository"/> class.
@@ -29,17 +32,6 @@ namespace Emby.Server.Implementations.Data
protected string DbFilePath { get; set; }
/// <summary>
- /// Gets or sets the number of write connections to create.
- /// </summary>
- /// <value>Path to the DB file.</value>
- protected int WriteConnectionsCount { get; set; } = 1;
-
- /// <summary>
- /// Gets or sets the number of read connections to create.
- /// </summary>
- protected int ReadConnectionsCount { get; set; } = 1;
-
- /// <summary>
/// Gets the logger.
/// </summary>
/// <value>The logger.</value>
@@ -98,9 +90,55 @@ namespace Emby.Server.Implementations.Data
}
}
- protected SqliteConnection GetConnection()
+ protected ManagedConnection GetConnection(bool readOnly = false)
{
- var connection = new SqliteConnection($"Filename={DbFilePath}");
+ if (!readOnly)
+ {
+ _writeLock.Wait();
+ if (_writeConnection is not null)
+ {
+ return new ManagedConnection(_writeConnection, _writeLock);
+ }
+
+ var writeConnection = new SqliteConnection($"Filename={DbFilePath};Pooling=False");
+ writeConnection.Open();
+
+ if (CacheSize.HasValue)
+ {
+ writeConnection.Execute("PRAGMA cache_size=" + CacheSize.Value);
+ }
+
+ if (!string.IsNullOrWhiteSpace(LockingMode))
+ {
+ writeConnection.Execute("PRAGMA locking_mode=" + LockingMode);
+ }
+
+ if (!string.IsNullOrWhiteSpace(JournalMode))
+ {
+ writeConnection.Execute("PRAGMA journal_mode=" + JournalMode);
+ }
+
+ if (JournalSizeLimit.HasValue)
+ {
+ writeConnection.Execute("PRAGMA journal_size_limit=" + JournalSizeLimit.Value);
+ }
+
+ if (Synchronous.HasValue)
+ {
+ writeConnection.Execute("PRAGMA synchronous=" + (int)Synchronous.Value);
+ }
+
+ if (PageSize.HasValue)
+ {
+ writeConnection.Execute("PRAGMA page_size=" + PageSize.Value);
+ }
+
+ writeConnection.Execute("PRAGMA temp_store=" + (int)TempStore);
+
+ return new ManagedConnection(_writeConnection = writeConnection, _writeLock);
+ }
+
+ var connection = new SqliteConnection($"Filename={DbFilePath};Mode=ReadOnly");
connection.Open();
if (CacheSize.HasValue)
@@ -135,17 +173,17 @@ namespace Emby.Server.Implementations.Data
connection.Execute("PRAGMA temp_store=" + (int)TempStore);
- return connection;
+ return new ManagedConnection(connection, null);
}
- public SqliteCommand PrepareStatement(SqliteConnection connection, string sql)
+ public SqliteCommand PrepareStatement(ManagedConnection connection, string sql)
{
var command = connection.CreateCommand();
command.CommandText = sql;
return command;
}
- protected bool TableExists(SqliteConnection connection, string name)
+ protected bool TableExists(ManagedConnection connection, string name)
{
using var statement = PrepareStatement(connection, "select DISTINCT tbl_name from sqlite_master");
foreach (var row in statement.ExecuteQuery())
@@ -159,7 +197,7 @@ namespace Emby.Server.Implementations.Data
return false;
}
- protected List<string> GetColumnNames(SqliteConnection connection, string table)
+ protected List<string> GetColumnNames(ManagedConnection connection, string table)
{
var columnNames = new List<string>();
@@ -174,7 +212,7 @@ namespace Emby.Server.Implementations.Data
return columnNames;
}
- protected void AddColumn(SqliteConnection connection, string table, string columnName, string type, List<string> existingColumnNames)
+ protected void AddColumn(ManagedConnection connection, string table, string columnName, string type, List<string> existingColumnNames)
{
if (existingColumnNames.Contains(columnName, StringComparison.OrdinalIgnoreCase))
{
@@ -207,6 +245,24 @@ namespace Emby.Server.Implementations.Data
return;
}
+ if (dispose)
+ {
+ _writeLock.Wait();
+ try
+ {
+ _writeConnection.Dispose();
+ }
+ finally
+ {
+ _writeLock.Release();
+ }
+
+ _writeLock.Dispose();
+ }
+
+ _writeConnection = null;
+ _writeLock = null;
+
_disposed = true;
}
}
diff --git a/Emby.Server.Implementations/Data/ManagedConnection.cs b/Emby.Server.Implementations/Data/ManagedConnection.cs
new file mode 100644
index 000000000..860950b30
--- /dev/null
+++ b/Emby.Server.Implementations/Data/ManagedConnection.cs
@@ -0,0 +1,62 @@
+#pragma warning disable CS1591
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using Microsoft.Data.Sqlite;
+
+namespace Emby.Server.Implementations.Data;
+
+public sealed class ManagedConnection : IDisposable
+{
+ private readonly SemaphoreSlim? _writeLock;
+
+ private SqliteConnection _db;
+
+ private bool _disposed = false;
+
+ public ManagedConnection(SqliteConnection db, SemaphoreSlim? writeLock)
+ {
+ _db = db;
+ _writeLock = writeLock;
+ }
+
+ public SqliteTransaction BeginTransaction()
+ => _db.BeginTransaction();
+
+ public SqliteCommand CreateCommand()
+ => _db.CreateCommand();
+
+ public void Execute(string commandText)
+ => _db.Execute(commandText);
+
+ public SqliteCommand PrepareStatement(string sql)
+ => _db.PrepareStatement(sql);
+
+ public IEnumerable<SqliteDataReader> Query(string commandText)
+ => _db.Query(commandText);
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ if (_writeLock is null)
+ {
+ // Read connections are managed with an internal pool
+ _db.Dispose();
+ }
+ else
+ {
+ // Write lock is managed by BaseSqliteRepository
+ // Don't dispose here
+ _writeLock.Release();
+ }
+
+ _db = null!;
+
+ _disposed = true;
+ }
+}
diff --git a/Emby.Server.Implementations/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs
index 34d753093..81ee55d26 100644
--- a/Emby.Server.Implementations/Data/SqliteItemRepository.cs
+++ b/Emby.Server.Implementations/Data/SqliteItemRepository.cs
@@ -327,7 +327,6 @@ namespace Emby.Server.Implementations.Data
DbFilePath = Path.Combine(_config.ApplicationPaths.DataPath, "library.db");
CacheSize = configuration.GetSqliteCacheSize();
- ReadConnectionsCount = Environment.ProcessorCount * 2;
}
/// <inheritdoc />
@@ -601,7 +600,7 @@ namespace Emby.Server.Implementations.Data
transaction.Commit();
}
- private void SaveItemsInTransaction(SqliteConnection db, IEnumerable<(BaseItem Item, List<Guid> AncestorIds, BaseItem TopParent, string UserDataKey, List<string> InheritedTags)> tuples)
+ private void SaveItemsInTransaction(ManagedConnection db, IEnumerable<(BaseItem Item, List<Guid> AncestorIds, BaseItem TopParent, string UserDataKey, List<string> InheritedTags)> tuples)
{
using (var saveItemStatement = PrepareStatement(db, SaveItemCommandText))
using (var deleteAncestorsStatement = PrepareStatement(db, "delete from AncestorIds where ItemId=@ItemId"))
@@ -1261,7 +1260,7 @@ namespace Emby.Server.Implementations.Data
CheckDisposed();
- using (var connection = GetConnection())
+ using (var connection = GetConnection(true))
using (var statement = PrepareStatement(connection, _retrieveItemColumnsSelectQuery))
{
statement.TryBind("@guid", id);
@@ -1298,16 +1297,15 @@ namespace Emby.Server.Implementations.Data
&& type != typeof(Book)
&& type != typeof(LiveTvProgram)
&& type != typeof(AudioBook)
- && type != typeof(Audio)
&& type != typeof(MusicAlbum);
}
private BaseItem GetItem(SqliteDataReader reader, InternalItemsQuery query)
{
- return GetItem(reader, query, HasProgramAttributes(query), HasEpisodeAttributes(query), HasServiceName(query), HasStartDate(query), HasTrailerTypes(query), HasArtistFields(query), HasSeriesFields(query));
+ return GetItem(reader, query, HasProgramAttributes(query), HasEpisodeAttributes(query), HasServiceName(query), HasStartDate(query), HasTrailerTypes(query), HasArtistFields(query), HasSeriesFields(query), false);
}
- private BaseItem GetItem(SqliteDataReader reader, InternalItemsQuery query, bool enableProgramAttributes, bool hasEpisodeAttributes, bool hasServiceName, bool queryHasStartDate, bool hasTrailerTypes, bool hasArtistFields, bool hasSeriesFields)
+ private BaseItem GetItem(SqliteDataReader reader, InternalItemsQuery query, bool enableProgramAttributes, bool hasEpisodeAttributes, bool hasServiceName, bool queryHasStartDate, bool hasTrailerTypes, bool hasArtistFields, bool hasSeriesFields, bool skipDeserialization)
{
var typeString = reader.GetString(0);
@@ -1320,7 +1318,7 @@ namespace Emby.Server.Implementations.Data
BaseItem item = null;
- if (TypeRequiresDeserialization(type))
+ if (TypeRequiresDeserialization(type) && !skipDeserialization)
{
try
{
@@ -1888,7 +1886,7 @@ namespace Emby.Server.Implementations.Data
CheckDisposed();
var chapters = new List<ChapterInfo>();
- using (var connection = GetConnection())
+ using (var connection = GetConnection(true))
using (var statement = PrepareStatement(connection, "select StartPositionTicks,Name,ImagePath,ImageDateModified from " + ChaptersTableName + " where ItemId = @ItemId order by ChapterIndex asc"))
{
statement.TryBind("@ItemId", item.Id);
@@ -1907,7 +1905,7 @@ namespace Emby.Server.Implementations.Data
{
CheckDisposed();
- using (var connection = GetConnection())
+ using (var connection = GetConnection(true))
using (var statement = PrepareStatement(connection, "select StartPositionTicks,Name,ImagePath,ImageDateModified from " + ChaptersTableName + " where ItemId = @ItemId and ChapterIndex=@ChapterIndex"))
{
statement.TryBind("@ItemId", item.Id);
@@ -1981,7 +1979,7 @@ namespace Emby.Server.Implementations.Data
transaction.Commit();
}
- private void InsertChapters(Guid idBlob, IReadOnlyList<ChapterInfo> chapters, SqliteConnection db)
+ private void InsertChapters(Guid idBlob, IReadOnlyList<ChapterInfo> chapters, ManagedConnection db)
{
var startIndex = 0;
var limit = 100;
@@ -2470,7 +2468,7 @@ namespace Emby.Server.Implementations.Data
var commandText = commandTextBuilder.ToString();
using (new QueryTimeLogger(Logger, commandText))
- using (var connection = GetConnection())
+ using (var connection = GetConnection(true))
using (var statement = PrepareStatement(connection, commandText))
{
if (EnableJoinUserData(query))
@@ -2538,7 +2536,7 @@ namespace Emby.Server.Implementations.Data
var commandText = commandTextBuilder.ToString();
var items = new List<BaseItem>();
using (new QueryTimeLogger(Logger, commandText))
- using (var connection = GetConnection())
+ using (var connection = GetConnection(true))
using (var statement = PrepareStatement(connection, commandText))
{
if (EnableJoinUserData(query))
@@ -2562,7 +2560,7 @@ namespace Emby.Server.Implementations.Data
foreach (var row in statement.ExecuteQuery())
{
- var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields);
+ var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields, query.SkipDeserialization);
if (item is not null)
{
items.Add(item);
@@ -2746,7 +2744,7 @@ namespace Emby.Server.Implementations.Data
var list = new List<BaseItem>();
var result = new QueryResult<BaseItem>();
- using var connection = GetConnection();
+ using var connection = GetConnection(true);
using var transaction = connection.BeginTransaction();
if (!isReturningZeroItems)
{
@@ -2774,7 +2772,7 @@ namespace Emby.Server.Implementations.Data
foreach (var row in statement.ExecuteQuery())
{
- var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields);
+ var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields, false);
if (item is not null)
{
list.Add(item);
@@ -2928,7 +2926,7 @@ namespace Emby.Server.Implementations.Data
var commandText = commandTextBuilder.ToString();
var list = new List<Guid>();
using (new QueryTimeLogger(Logger, commandText))
- using (var connection = GetConnection())
+ using (var connection = GetConnection(true))
using (var statement = PrepareStatement(connection, commandText))
{
if (EnableJoinUserData(query))
@@ -4477,7 +4475,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
transaction.Commit();
}
- private void ExecuteWithSingleParam(SqliteConnection db, string query, Guid value)
+ private void ExecuteWithSingleParam(ManagedConnection db, string query, Guid value)
{
using (var statement = PrepareStatement(db, query))
{
@@ -4510,7 +4508,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
}
var list = new List<string>();
- using (var connection = GetConnection())
+ using (var connection = GetConnection(true))
using (var statement = PrepareStatement(connection, commandText.ToString()))
{
// Run this again to bind the params
@@ -4548,7 +4546,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
}
var list = new List<PersonInfo>();
- using (var connection = GetConnection())
+ using (var connection = GetConnection(true))
using (var statement = PrepareStatement(connection, commandText.ToString()))
{
// Run this again to bind the params
@@ -4633,7 +4631,7 @@ AND Type = @InternalPersonType)");
return whereClauses;
}
- private void UpdateAncestors(Guid itemId, List<Guid> ancestorIds, SqliteConnection db, SqliteCommand deleteAncestorsStatement)
+ private void UpdateAncestors(Guid itemId, List<Guid> ancestorIds, ManagedConnection db, SqliteCommand deleteAncestorsStatement)
{
if (itemId.IsEmpty())
{
@@ -4788,7 +4786,7 @@ AND Type = @InternalPersonType)");
var list = new List<string>();
using (new QueryTimeLogger(Logger, commandText))
- using (var connection = GetConnection())
+ using (var connection = GetConnection(true))
using (var statement = PrepareStatement(connection, commandText))
{
foreach (var row in statement.ExecuteQuery())
@@ -4988,8 +4986,8 @@ AND Type = @InternalPersonType)");
var list = new List<(BaseItem, ItemCounts)>();
var result = new QueryResult<(BaseItem, ItemCounts)>();
using (new QueryTimeLogger(Logger, commandText))
- using (var connection = GetConnection())
- using (var transaction = connection.BeginTransaction(deferred: true))
+ using (var connection = GetConnection(true))
+ using (var transaction = connection.BeginTransaction())
{
if (!isReturningZeroItems)
{
@@ -5021,7 +5019,7 @@ AND Type = @InternalPersonType)");
foreach (var row in statement.ExecuteQuery())
{
- var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields);
+ var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields, false);
if (item is not null)
{
var countStartColumn = columns.Count - 1;
@@ -5149,7 +5147,7 @@ AND Type = @InternalPersonType)");
return list;
}
- private void UpdateItemValues(Guid itemId, List<(int MagicNumber, string Value)> values, SqliteConnection db)
+ private void UpdateItemValues(Guid itemId, List<(int MagicNumber, string Value)> values, ManagedConnection db)
{
if (itemId.IsEmpty())
{
@@ -5168,7 +5166,7 @@ AND Type = @InternalPersonType)");
InsertItemValues(itemId, values, db);
}
- private void InsertItemValues(Guid id, List<(int MagicNumber, string Value)> values, SqliteConnection db)
+ private void InsertItemValues(Guid id, List<(int MagicNumber, string Value)> values, ManagedConnection db)
{
const int Limit = 100;
var startIndex = 0;
@@ -5222,24 +5220,25 @@ AND Type = @InternalPersonType)");
throw new ArgumentNullException(nameof(itemId));
}
- ArgumentNullException.ThrowIfNull(people);
-
CheckDisposed();
using var connection = GetConnection();
using var transaction = connection.BeginTransaction();
- // First delete chapters
+ // Delete all existing people first
using var command = connection.CreateCommand();
command.CommandText = "delete from People where ItemId=@ItemId";
command.TryBind("@ItemId", itemId);
command.ExecuteNonQuery();
- InsertPeople(itemId, people, connection);
+ if (people is not null)
+ {
+ InsertPeople(itemId, people, connection);
+ }
transaction.Commit();
}
- private void InsertPeople(Guid id, List<PersonInfo> people, SqliteConnection db)
+ private void InsertPeople(Guid id, List<PersonInfo> people, ManagedConnection db)
{
const int Limit = 100;
var startIndex = 0;
@@ -5335,7 +5334,7 @@ AND Type = @InternalPersonType)");
cmdText += " order by StreamIndex ASC";
- using (var connection = GetConnection())
+ using (var connection = GetConnection(true))
{
var list = new List<MediaStream>();
@@ -5388,7 +5387,7 @@ AND Type = @InternalPersonType)");
transaction.Commit();
}
- private void InsertMediaStreams(Guid id, IReadOnlyList<MediaStream> streams, SqliteConnection db)
+ private void InsertMediaStreams(Guid id, IReadOnlyList<MediaStream> streams, ManagedConnection db)
{
const int Limit = 10;
var startIndex = 0;
@@ -5722,7 +5721,7 @@ AND Type = @InternalPersonType)");
cmdText += " order by AttachmentIndex ASC";
var list = new List<MediaAttachment>();
- using (var connection = GetConnection())
+ using (var connection = GetConnection(true))
using (var statement = PrepareStatement(connection, cmdText))
{
statement.TryBind("@ItemId", query.ItemId);
@@ -5772,7 +5771,7 @@ AND Type = @InternalPersonType)");
private void InsertMediaAttachments(
Guid id,
IReadOnlyList<MediaAttachment> attachments,
- SqliteConnection db,
+ ManagedConnection db,
CancellationToken cancellationToken)
{
const int InsertAtOnce = 10;
diff --git a/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs b/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs
index 20359e4ad..634eaf85e 100644
--- a/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs
+++ b/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs
@@ -86,7 +86,7 @@ namespace Emby.Server.Implementations.Data
}
}
- private void ImportUserIds(SqliteConnection db, IEnumerable<User> users)
+ private void ImportUserIds(ManagedConnection db, IEnumerable<User> users)
{
var userIdsWithUserData = GetAllUserIdsWithUserData(db);
@@ -107,7 +107,7 @@ namespace Emby.Server.Implementations.Data
}
}
- private List<Guid> GetAllUserIdsWithUserData(SqliteConnection db)
+ private List<Guid> GetAllUserIdsWithUserData(ManagedConnection db)
{
var list = new List<Guid>();
@@ -176,7 +176,7 @@ namespace Emby.Server.Implementations.Data
}
}
- private static void SaveUserData(SqliteConnection db, long internalUserId, string key, UserItemData userData)
+ private static void SaveUserData(ManagedConnection db, long internalUserId, string key, UserItemData userData)
{
using (var statement = db.PrepareStatement("replace into UserDatas (key, userId, rating,played,playCount,isFavorite,playbackPositionTicks,lastPlayedDate,AudioStreamIndex,SubtitleStreamIndex) values (@key, @userId, @rating,@played,@playCount,@isFavorite,@playbackPositionTicks,@lastPlayedDate,@AudioStreamIndex,@SubtitleStreamIndex)"))
{
@@ -267,7 +267,7 @@ namespace Emby.Server.Implementations.Data
ArgumentException.ThrowIfNullOrEmpty(key);
- using (var connection = GetConnection())
+ using (var connection = GetConnection(true))
{
using (var statement = connection.PrepareStatement("select key,userid,rating,played,playCount,isFavorite,playbackPositionTicks,lastPlayedDate,AudioStreamIndex,SubtitleStreamIndex from UserDatas where key =@Key and userId=@UserId"))
{
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index cca835e4f..953fe19e0 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -1029,7 +1029,7 @@ namespace Emby.Server.Implementations.Library
}
}
- private async Task ValidateTopLibraryFolders(CancellationToken cancellationToken, bool removeRoot = false)
+ public async Task ValidateTopLibraryFolders(CancellationToken cancellationToken, bool removeRoot = false)
{
await RootFolder.RefreshMetadata(cancellationToken).ConfigureAwait(false);
@@ -2812,8 +2812,10 @@ namespace Emby.Server.Implementations.Library
}
_itemRepository.UpdatePeople(item.Id, people);
-
- await SavePeopleMetadataAsync(people, cancellationToken).ConfigureAwait(false);
+ if (people is not null)
+ {
+ await SavePeopleMetadataAsync(people, cancellationToken).ConfigureAwait(false);
+ }
}
public async Task<ItemImageInfo> ConvertImageToLocal(BaseItem item, ItemImageInfo image, int imageIndex, bool removeOnFailure)
diff --git a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs
index 1bdae7f62..f7270bec1 100644
--- a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs
@@ -3,6 +3,7 @@
using System;
using System.Linq;
using System.Threading.Tasks;
+using Emby.Naming.Audio;
using Emby.Naming.Common;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Entities.Audio;
@@ -85,6 +86,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio
}
var albumResolver = new MusicAlbumResolver(_logger, _namingOptions, _directoryService);
+ var albumParser = new AlbumParser(_namingOptions);
var directories = args.FileSystemChildren.Where(i => i.IsDirectory);
@@ -100,6 +102,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio
}
}
+ // If the folder is a multi-disc folder, then it is not an artist folder
+ if (albumParser.IsMultiPart(fileSystemInfo.FullName))
+ {
+ return;
+ }
+
// If we contain a music album assume we are an artist folder
if (albumResolver.IsMusicAlbum(fileSystemInfo.FullName, _directoryService))
{
diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs
index ff4a88162..abf2d0115 100644
--- a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs
@@ -54,7 +54,8 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
{
IndexNumber = seasonParserResult.SeasonNumber,
SeriesId = series.Id,
- SeriesName = series.Name
+ SeriesName = series.Name,
+ Path = seasonParserResult.IsSeasonFolder ? path : null
};
if (!season.IndexNumber.HasValue || !seasonParserResult.IsSeasonFolder)
@@ -78,27 +79,16 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
}
}
- if (season.IndexNumber.HasValue)
+ if (season.IndexNumber.HasValue && string.IsNullOrEmpty(season.Name))
{
var seasonNumber = season.IndexNumber.Value;
- if (string.IsNullOrEmpty(season.Name))
- {
- var seasonNames = series.GetSeasonNames();
- if (seasonNames.TryGetValue(seasonNumber, out var seasonName))
- {
- season.Name = seasonName;
- }
- else
- {
- season.Name = seasonNumber == 0 ?
- args.LibraryOptions.SeasonZeroDisplayName :
- string.Format(
- CultureInfo.InvariantCulture,
- _localization.GetLocalizedString("NameSeasonNumber"),
- seasonNumber,
- args.LibraryOptions.PreferredMetadataLanguage);
- }
- }
+ season.Name = seasonNumber == 0 ?
+ args.LibraryOptions.SeasonZeroDisplayName :
+ string.Format(
+ CultureInfo.InvariantCulture,
+ _localization.GetLocalizedString("NameSeasonNumber"),
+ seasonNumber,
+ args.LibraryOptions.PreferredMetadataLanguage);
}
return season;
diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json
index b5e2c9b6b..e871a4362 100644
--- a/Emby.Server.Implementations/Localization/Core/da.json
+++ b/Emby.Server.Implementations/Localization/Core/da.json
@@ -17,7 +17,7 @@
"Genres": "Genrer",
"HeaderAlbumArtists": "Albumkunstnere",
"HeaderContinueWatching": "Fortsæt afspilning",
- "HeaderFavoriteAlbums": "Favoritalbummer",
+ "HeaderFavoriteAlbums": "Favoritalbum",
"HeaderFavoriteArtists": "Favoritkunstnere",
"HeaderFavoriteEpisodes": "Yndlingsafsnit",
"HeaderFavoriteShows": "Yndlingsserier",
@@ -87,21 +87,21 @@
"UserOnlineFromDevice": "{0} er online fra {1}",
"UserPasswordChangedWithName": "Adgangskode er ændret for brugeren {0}",
"UserPolicyUpdatedWithName": "Brugerpolitikken er blevet opdateret for {0}",
- "UserStartedPlayingItemWithValues": "{0} har påbegyndt afspilning af {1}",
+ "UserStartedPlayingItemWithValues": "{0} afspiller {1} på {2}",
"UserStoppedPlayingItemWithValues": "{0} har afsluttet afspilning af {1} på {2}",
"ValueHasBeenAddedToLibrary": "{0} er blevet tilføjet til dit mediebibliotek",
"ValueSpecialEpisodeName": "Special - {0}",
"VersionNumber": "Version {0}",
"TaskDownloadMissingSubtitlesDescription": "Søger på internettet efter manglende undertekster baseret på metadata-konfigurationen.",
"TaskDownloadMissingSubtitles": "Hent manglende undertekster",
- "TaskUpdatePluginsDescription": "Henter og installerer opdateringer for plugins, som er indstillet til at blive opdateret automatisk.",
+ "TaskUpdatePluginsDescription": "Henter og installerer opdateringer for plugins, som er konfigurerede til at blive opdateret automatisk.",
"TaskUpdatePlugins": "Opdater Plugins",
"TaskCleanLogsDescription": "Sletter log-filer som er mere end {0} dage gamle.",
"TaskCleanLogs": "Ryd Log-mappe",
"TaskRefreshLibraryDescription": "Scanner dit mediebibliotek for nye filer og opdateret metadata.",
"TaskRefreshLibrary": "Scan Mediebibliotek",
"TaskCleanCacheDescription": "Sletter cache-filer som systemet ikke længere bruger.",
- "TaskCleanCache": "Ryd Cache-mappe",
+ "TaskCleanCache": "Ryd cache-mappe",
"TasksChannelsCategory": "Internetkanaler",
"TasksApplicationCategory": "Applikation",
"TasksLibraryCategory": "Bibliotek",
@@ -128,5 +128,7 @@
"TaskRefreshTrickplayImages": "Generér Trickplay Billeder",
"TaskRefreshTrickplayImagesDescription": "Laver trickplay forhåndsvisninger for videoer i aktiverede biblioteker.",
"TaskCleanCollectionsAndPlaylists": "Ryd op i samlinger og afspilningslister",
- "TaskCleanCollectionsAndPlaylistsDescription": "Fjerner enheder fra samlinger og afspilningslister der ikke eksisterer længere."
+ "TaskCleanCollectionsAndPlaylistsDescription": "Fjerner elementer fra samlinger og afspilningslister der ikke eksisterer længere.",
+ "TaskAudioNormalizationDescription": "Skanner filer for data vedrørende audio-normalisering.",
+ "TaskAudioNormalization": "Audio-normalisering"
}
diff --git a/Emby.Server.Implementations/Localization/Core/de.json b/Emby.Server.Implementations/Localization/Core/de.json
index 68a0180eb..ce98979e6 100644
--- a/Emby.Server.Implementations/Localization/Core/de.json
+++ b/Emby.Server.Implementations/Localization/Core/de.json
@@ -126,7 +126,7 @@
"External": "Extern",
"HearingImpaired": "Hörgeschädigt",
"TaskRefreshTrickplayImages": "Trickplay-Bilder generieren",
- "TaskRefreshTrickplayImagesDescription": "Erstellt eine Trickplay-Vorschau für Videos in aktivierten Bibliotheken.",
+ "TaskRefreshTrickplayImagesDescription": "Erstellt ein Trickplay-Vorschauen für Videos in aktivierten Bibliotheken.",
"TaskCleanCollectionsAndPlaylists": "Sammlungen und Playlisten aufräumen",
"TaskCleanCollectionsAndPlaylistsDescription": "Lösche nicht mehr vorhandene Einträge aus den Sammlungen und Playlisten.",
"TaskAudioNormalization": "Audio Normalisierung",
diff --git a/Emby.Server.Implementations/Localization/Core/es-MX.json b/Emby.Server.Implementations/Localization/Core/es-MX.json
index d4151adf3..e9ace71a5 100644
--- a/Emby.Server.Implementations/Localization/Core/es-MX.json
+++ b/Emby.Server.Implementations/Localization/Core/es-MX.json
@@ -11,7 +11,7 @@
"Collections": "Colecciones",
"DeviceOfflineWithName": "{0} se ha desconectado",
"DeviceOnlineWithName": "{0} está conectado",
- "FailedLoginAttemptWithUserName": "Intento fallido de inicio de sesión desde {0}",
+ "FailedLoginAttemptWithUserName": "Intento fallido de inicio de sesión de {0}",
"Favorites": "Favoritos",
"Folders": "Carpetas",
"Genres": "Géneros",
diff --git a/Emby.Server.Implementations/Localization/Core/es.json b/Emby.Server.Implementations/Localization/Core/es.json
index f9d62d54e..13e007b4c 100644
--- a/Emby.Server.Implementations/Localization/Core/es.json
+++ b/Emby.Server.Implementations/Localization/Core/es.json
@@ -11,7 +11,7 @@
"Collections": "Colecciones",
"DeviceOfflineWithName": "{0} se ha desconectado",
"DeviceOnlineWithName": "{0} está conectado",
- "FailedLoginAttemptWithUserName": "Error al intentar iniciar sesión desde {0}",
+ "FailedLoginAttemptWithUserName": "Intento fallido de inicio de sesión de {0}",
"Favorites": "Favoritos",
"Folders": "Carpetas",
"Genres": "Géneros",
diff --git a/Emby.Server.Implementations/Localization/Core/es_419.json b/Emby.Server.Implementations/Localization/Core/es_419.json
index c6863ff36..e7deefbb0 100644
--- a/Emby.Server.Implementations/Localization/Core/es_419.json
+++ b/Emby.Server.Implementations/Localization/Core/es_419.json
@@ -112,7 +112,7 @@
"CameraImageUploadedFrom": "Una nueva imagen de cámara ha sido subida desde {0}",
"AuthenticationSucceededWithUserName": "{0} autenticado con éxito",
"Application": "Aplicación",
- "AppDeviceValues": "App: {0}, Dispositivo: {1}",
+ "AppDeviceValues": "Aplicación: {0}, Dispositivo: {1}",
"TaskCleanActivityLogDescription": "Elimina las entradas del registro de actividad anteriores al periodo configurado.",
"TaskCleanActivityLog": "Limpiar registro de actividades",
"Undefined": "Sin definir",
@@ -125,5 +125,9 @@
"TaskKeyframeExtractor": "Extractor de Fotogramas Clave",
"HearingImpaired": "Discapacidad auditiva",
"TaskRefreshTrickplayImagesDescription": "Crea previsualizaciones para la barra de reproducción en las bibliotecas habilitadas.",
- "TaskRefreshTrickplayImages": "Generar imágenes de la barra de reproducción"
+ "TaskRefreshTrickplayImages": "Generar imágenes de la barra de reproducción",
+ "TaskAudioNormalization": "Normalización de audio",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Quita elementos que ya no existen de colecciones y listas de reproducción.",
+ "TaskAudioNormalizationDescription": "Analiza los archivos para normalizar el audio.",
+ "TaskCleanCollectionsAndPlaylists": "Limpieza de colecciones y listas de reproducción"
}
diff --git a/Emby.Server.Implementations/Localization/Core/es_DO.json b/Emby.Server.Implementations/Localization/Core/es_DO.json
index 0f4c7438f..8cdd06b7c 100644
--- a/Emby.Server.Implementations/Localization/Core/es_DO.json
+++ b/Emby.Server.Implementations/Localization/Core/es_DO.json
@@ -12,14 +12,118 @@
"Application": "Aplicación",
"AppDeviceValues": "App: {0}, Dispositivo: {1}",
"HeaderContinueWatching": "Continuar Viendo",
- "HeaderAlbumArtists": "Artistas del Álbum",
+ "HeaderAlbumArtists": "Artistas del álbum",
"Genres": "Géneros",
"Folders": "Carpetas",
"Favorites": "Favoritos",
- "FailedLoginAttemptWithUserName": "Intento de inicio de sesión fallido de {0}",
+ "FailedLoginAttemptWithUserName": "Intento de inicio de sesión fallido desde {0}",
"HeaderFavoriteSongs": "Canciones Favoritas",
"HeaderFavoriteEpisodes": "Episodios Favoritos",
"HeaderFavoriteArtists": "Artistas Favoritos",
"External": "Externo",
- "Default": "Predeterminado"
+ "Default": "Predeterminado",
+ "Movies": "Películas",
+ "MessageNamedServerConfigurationUpdatedWithValue": "La sección {0} de la configuración ha sido actualizada",
+ "MixedContent": "Contenido mixto",
+ "Music": "Música",
+ "NotificationOptionCameraImageUploaded": "Imagen de la cámara subida",
+ "NotificationOptionServerRestartRequired": "Se necesita reiniciar el servidor",
+ "NotificationOptionVideoPlayback": "Reproducción de video iniciada",
+ "Sync": "Sincronizar",
+ "Shows": "Series",
+ "UserDownloadingItemWithValues": "{0} está descargando {1}",
+ "UserOfflineFromDevice": "{0} se ha desconectado desde {1}",
+ "UserOnlineFromDevice": "{0} está en línea desde {1}",
+ "TasksChannelsCategory": "Canales de Internet",
+ "TaskRefreshChannelsDescription": "Actualiza la información de canales de Internet.",
+ "TaskDownloadMissingSubtitles": "Descargar subtítulos faltantes",
+ "TaskOptimizeDatabaseDescription": "Compacta la base de datos y libera espacio. Ejecutar esta tarea después de escanear la biblioteca o hacer otros cambios que impliquen modificaciones en la base de datos puede mejorar el rendimiento.",
+ "TaskKeyframeExtractorDescription": "Extrae Fotogramas Clave de los archivos de vídeo para crear Listas de Reproducción HLS más precisas. Esta tarea puede durar mucho tiempo.",
+ "TaskAudioNormalization": "Normalización de audio",
+ "TaskAudioNormalizationDescription": "Escanear archivos para la normalización de data.",
+ "TaskCleanCollectionsAndPlaylists": "Limpiar colecciones y listas de reproducción",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Remover elementos de colecciones y listas de reproducción que no existen.",
+ "TvShows": "Series de TV",
+ "UserStartedPlayingItemWithValues": "{0} está reproduciendo {1} en {2}",
+ "TaskRefreshChannels": "Actualizar canales",
+ "Photos": "Fotos",
+ "HeaderFavoriteShows": "Programas favoritos",
+ "TaskCleanActivityLog": "Limpiar registro de actividades",
+ "UserPasswordChangedWithName": "Se ha cambiado la contraseña para el usuario {0}",
+ "System": "Sistema",
+ "User": "Usuario",
+ "Forced": "Forzado",
+ "PluginInstalledWithName": "{0} ha sido instalado",
+ "HeaderFavoriteAlbums": "Álbumes favoritos",
+ "TaskUpdatePlugins": "Actualizar Plugins",
+ "Latest": "Recientes",
+ "UserStoppedPlayingItemWithValues": "{0} ha terminado de reproducir {1} en {2}",
+ "Songs": "Canciones",
+ "NotificationOptionPluginError": "Falla de plugin",
+ "ScheduledTaskStartedWithName": "{0} iniciado",
+ "TasksApplicationCategory": "Aplicación",
+ "UserDeletedWithName": "El usuario {0} ha sido eliminado",
+ "TaskRefreshChapterImages": "Extraer imágenes de los capítulos",
+ "TaskUpdatePluginsDescription": "Descarga e instala actualizaciones para plugins que están configurados para actualizarse automáticamente.",
+ "TaskRefreshPeopleDescription": "Actualiza metadatos de actores y directores en tu biblioteca de medios.",
+ "NotificationOptionUserLockedOut": "Usuario bloqueado",
+ "TaskCleanTranscodeDescription": "Elimina archivos transcodificados que tengan más de un día.",
+ "TaskCleanTranscode": "Limpiar el directorio de transcodificaciones",
+ "NotificationOptionPluginUpdateInstalled": "Actualización de plugin instalada",
+ "NotificationOptionAudioPlaybackStopped": "Reproducción de audio detenida",
+ "TasksLibraryCategory": "Biblioteca",
+ "NotificationOptionPluginInstalled": "Plugin instalado",
+ "UserPolicyUpdatedWithName": "La política de usuario ha sido actualizada para {0}",
+ "VersionNumber": "Versión {0}",
+ "HeaderNextUp": "A continuación",
+ "ValueHasBeenAddedToLibrary": "{0} se ha añadido a tu biblioteca",
+ "LabelIpAddressValue": "Dirección IP: {0}",
+ "NameSeasonNumber": "Temporada {0}",
+ "NotificationOptionNewLibraryContent": "Nuevo contenido agregado",
+ "Plugin": "Plugin",
+ "NotificationOptionAudioPlayback": "Reproducción de audio iniciada",
+ "NotificationOptionTaskFailed": "Falló la tarea programada",
+ "LabelRunningTimeValue": "Tiempo en ejecución: {0}",
+ "SubtitleDownloadFailureFromForItem": "Falló la descarga de subtítulos desde {0} para {1}",
+ "TaskRefreshLibrary": "Escanear biblioteca de medios",
+ "ServerNameNeedsToBeRestarted": "{0} debe ser reiniciado",
+ "TasksMaintenanceCategory": "Mantenimiento",
+ "ProviderValue": "Proveedor: {0}",
+ "UserCreatedWithName": "El usuario {0} ha sido creado",
+ "PluginUninstalledWithName": "{0} ha sido desinstalado",
+ "ValueSpecialEpisodeName": "Especial - {0}",
+ "ScheduledTaskFailedWithName": "{0} falló",
+ "TaskCleanLogs": "Limpiar directorio de registros",
+ "NameInstallFailed": "Falló la instalación de {0}",
+ "UserLockedOutWithName": "El usuario {0} ha sido bloqueado",
+ "TaskRefreshLibraryDescription": "Escanea tu biblioteca de medios para encontrar archivos nuevos y actualizar los metadatos.",
+ "StartupEmbyServerIsLoading": "El servidor Jellyfin está cargando. Por favor, intente de nuevo en un momento.",
+ "Playlists": "Listas de reproducción",
+ "TaskDownloadMissingSubtitlesDescription": "Busca subtítulos faltantes en Internet basándose en la configuración de metadatos.",
+ "MessageServerConfigurationUpdated": "Se ha actualizado la configuración del servidor",
+ "TaskRefreshPeople": "Actualizar personas",
+ "NotificationOptionVideoPlaybackStopped": "Reproducción de video detenida",
+ "HeaderLiveTV": "TV en vivo",
+ "NameSeasonUnknown": "Temporada desconocida",
+ "NotificationOptionInstallationFailed": "Fallo de instalación",
+ "NotificationOptionPluginUninstalled": "Plugin desinstalado",
+ "TaskCleanCache": "Limpiar directorio caché",
+ "TaskRefreshChapterImagesDescription": "Crea miniaturas para videos que tienen capítulos.",
+ "Inherit": "Heredar",
+ "HeaderRecordingGroups": "Grupos de grabación",
+ "ItemAddedWithName": "{0} fue agregado a la biblioteca",
+ "TaskOptimizeDatabase": "Optimizar base de datos",
+ "TaskKeyframeExtractor": "Extractor de Fotogramas Clave",
+ "HearingImpaired": "Discapacidad auditiva",
+ "HomeVideos": "Videos caseros",
+ "ItemRemovedWithName": "{0} fue removido de la biblioteca",
+ "MessageApplicationUpdated": "El servidor Jellyfin ha sido actualizado",
+ "MessageApplicationUpdatedTo": "El servidor Jellyfin ha sido actualizado a {0}",
+ "MusicVideos": "Videos musicales",
+ "NewVersionIsAvailable": "Una nueva versión de Jellyfin está disponible para descargar.",
+ "PluginUpdatedWithName": "{0} ha sido actualizado",
+ "Undefined": "Sin definir",
+ "TaskCleanActivityLogDescription": "Elimina las entradas del registro de actividad anteriores al periodo configurado.",
+ "TaskCleanCacheDescription": "Elimina archivos caché que ya no son necesarios para el sistema.",
+ "TaskCleanLogsDescription": "Elimina archivos de registro con más de {0} días de antigüedad."
}
diff --git a/Emby.Server.Implementations/Localization/Core/ga.json b/Emby.Server.Implementations/Localization/Core/ga.json
index 28e54bff5..b511ed6ba 100644
--- a/Emby.Server.Implementations/Localization/Core/ga.json
+++ b/Emby.Server.Implementations/Localization/Core/ga.json
@@ -1,3 +1,16 @@
{
- "Albums": "Albaim"
+ "Albums": "Albaim",
+ "Artists": "Ealaíontóir",
+ "AuthenticationSucceededWithUserName": "{0} fíordheimhnithe",
+ "Books": "leabhair",
+ "CameraImageUploadedFrom": "Tá íomhá ceamara nua uaslódáilte ó {0}",
+ "Channels": "Cainéil",
+ "ChapterNameValue": "Caibidil {0}",
+ "Collections": "Bailiúcháin",
+ "Default": "Mainneachtain",
+ "DeviceOfflineWithName": "scoireadh {0}",
+ "DeviceOnlineWithName": "{0} ceangailte",
+ "External": "Forimeallach",
+ "FailedLoginAttemptWithUserName": "Iarracht ar theip ar fhíordheimhniú ó {0}",
+ "Favorites": "Ceanáin"
}
diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json
index 26eab392e..c8e036424 100644
--- a/Emby.Server.Implementations/Localization/Core/he.json
+++ b/Emby.Server.Implementations/Localization/Core/he.json
@@ -126,5 +126,9 @@
"External": "חיצוני",
"HearingImpaired": "לקוי שמיעה",
"TaskRefreshTrickplayImages": "יצירת תמונות המחשה",
- "TaskRefreshTrickplayImagesDescription": "יוצר תמונות המחשה לסרטונים שפעילים בספריות."
+ "TaskRefreshTrickplayImagesDescription": "יוצר תמונות המחשה לסרטונים שפעילים בספריות.",
+ "TaskAudioNormalization": "נרמול שמע",
+ "TaskCleanCollectionsAndPlaylistsDescription": "מנקה פריטים לא קיימים מאוספים ורשימות השמעה.",
+ "TaskAudioNormalizationDescription": "מחפש קבצי נורמליזציה של שמע.",
+ "TaskCleanCollectionsAndPlaylists": "מנקה אוספים ורשימות השמעה"
}
diff --git a/Emby.Server.Implementations/Localization/Core/hu.json b/Emby.Server.Implementations/Localization/Core/hu.json
index 5d5bb3324..31d6aaedb 100644
--- a/Emby.Server.Implementations/Localization/Core/hu.json
+++ b/Emby.Server.Implementations/Localization/Core/hu.json
@@ -18,14 +18,14 @@
"HeaderAlbumArtists": "Album előadók",
"HeaderContinueWatching": "Megtekintés folytatása",
"HeaderFavoriteAlbums": "Kedvenc Albumok",
- "HeaderFavoriteArtists": "Kedvenc előadók",
- "HeaderFavoriteEpisodes": "Kedvenc epizódok",
- "HeaderFavoriteShows": "Kedvenc sorozatok",
- "HeaderFavoriteSongs": "Kedvenc dalok",
+ "HeaderFavoriteArtists": "Kedvenc Előadók",
+ "HeaderFavoriteEpisodes": "Kedvenc Epizódok",
+ "HeaderFavoriteShows": "Kedvenc Sorozatok",
+ "HeaderFavoriteSongs": "Kedvenc Dalok",
"HeaderLiveTV": "Élő TV",
"HeaderNextUp": "Következik",
- "HeaderRecordingGroups": "Felvételi csoportok",
- "HomeVideos": "Otthoni videók",
+ "HeaderRecordingGroups": "Felvevő Csoportok",
+ "HomeVideos": "Otthoni Videók",
"Inherit": "Örökölt",
"ItemAddedWithName": "{0} hozzáadva a könyvtárhoz",
"ItemRemovedWithName": "{0} eltávolítva a könyvtárból",
diff --git a/Emby.Server.Implementations/Localization/Core/id.json b/Emby.Server.Implementations/Localization/Core/id.json
index 78a443348..b925a482b 100644
--- a/Emby.Server.Implementations/Localization/Core/id.json
+++ b/Emby.Server.Implementations/Localization/Core/id.json
@@ -81,7 +81,7 @@
"Movies": "Film",
"MessageServerConfigurationUpdated": "Konfigurasi server telah diperbarui",
"MessageNamedServerConfigurationUpdatedWithValue": "Bagian konfigurasi server {0} telah diperbarui",
- "FailedLoginAttemptWithUserName": "Gagal melakukan login dari {0}",
+ "FailedLoginAttemptWithUserName": "Gagal upaya login dari {0}",
"CameraImageUploadedFrom": "Sebuah gambar kamera baru telah diunggah dari {0}",
"DeviceOfflineWithName": "{0} telah terputus",
"DeviceOnlineWithName": "{0} telah terhubung",
@@ -125,5 +125,9 @@
"External": "Luar",
"HearingImpaired": "Gangguan Pendengaran",
"TaskRefreshTrickplayImages": "Hasilkan Gambar Trickplay",
- "TaskRefreshTrickplayImagesDescription": "Buat pratinjau trickplay untuk video di perpustakaan yang diaktifkan."
+ "TaskRefreshTrickplayImagesDescription": "Buat pratinjau trickplay untuk video di perpustakaan yang diaktifkan.",
+ "TaskAudioNormalizationDescription": "Pindai file untuk data normalisasi audio.",
+ "TaskAudioNormalization": "Normalisasi Audio",
+ "TaskCleanCollectionsAndPlaylists": "Bersihkan koleksi dan daftar putar",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Menghapus item dari koleksi dan daftar putar yang sudah tidak ada."
}
diff --git a/Emby.Server.Implementations/Localization/Core/it.json b/Emby.Server.Implementations/Localization/Core/it.json
index 783aecec7..0e694af02 100644
--- a/Emby.Server.Implementations/Localization/Core/it.json
+++ b/Emby.Server.Implementations/Localization/Core/it.json
@@ -51,10 +51,10 @@
"NotificationOptionCameraImageUploaded": "Immagine fotocamera caricata",
"NotificationOptionInstallationFailed": "Installazione fallita",
"NotificationOptionNewLibraryContent": "Nuovo contenuto aggiunto",
- "NotificationOptionPluginError": "Errore del Plug-in",
- "NotificationOptionPluginInstalled": "Plug-in installato",
- "NotificationOptionPluginUninstalled": "Plug-in disinstallato",
- "NotificationOptionPluginUpdateInstalled": "Aggiornamento del plug-in installato",
+ "NotificationOptionPluginError": "Errore del plugin",
+ "NotificationOptionPluginInstalled": "Plugin installato",
+ "NotificationOptionPluginUninstalled": "Plugin disinstallato",
+ "NotificationOptionPluginUpdateInstalled": "Aggiornamento plugin installato",
"NotificationOptionServerRestartRequired": "Riavvio del server necessario",
"NotificationOptionTaskFailed": "Operazione pianificata fallita",
"NotificationOptionUserLockedOut": "Utente bloccato",
@@ -68,10 +68,10 @@
"PluginUpdatedWithName": "{0} è stato aggiornato",
"ProviderValue": "Provider: {0}",
"ScheduledTaskFailedWithName": "{0} fallito",
- "ScheduledTaskStartedWithName": "{0} avviati",
+ "ScheduledTaskStartedWithName": "{0} avviato",
"ServerNameNeedsToBeRestarted": "{0} deve essere riavviato",
"Shows": "Serie TV",
- "Songs": "Canzoni",
+ "Songs": "Brani",
"StartupEmbyServerIsLoading": "Jellyfin server si sta avviando. Per favore riprova più tardi.",
"SubtitleDownloadFailureForItem": "Impossibile scaricare i sottotitoli per {0}",
"SubtitleDownloadFailureFromForItem": "Impossibile scaricare i sottotitoli da {0} per {1}",
@@ -87,48 +87,48 @@
"UserOnlineFromDevice": "{0} è online su {1}",
"UserPasswordChangedWithName": "La password è stata cambiata per l'utente {0}",
"UserPolicyUpdatedWithName": "La policy dell'utente è stata aggiornata per {0}",
- "UserStartedPlayingItemWithValues": "{0} ha avviato la riproduzione di \"{1}\" su {2}",
+ "UserStartedPlayingItemWithValues": "{0} ha avviato la riproduzione di {1} su {2}",
"UserStoppedPlayingItemWithValues": "{0} ha interrotto la riproduzione di {1} su {2}",
"ValueHasBeenAddedToLibrary": "{0} è stato aggiunto alla tua libreria multimediale",
"ValueSpecialEpisodeName": "Speciale - {0}",
"VersionNumber": "Versione {0}",
- "TaskRefreshChannelsDescription": "Aggiorna le informazioni dei canali Internet.",
+ "TaskRefreshChannelsDescription": "Aggiorna le informazioni dei canali internet.",
"TaskDownloadMissingSubtitlesDescription": "Cerca su internet i sottotitoli mancanti basandosi sulle configurazioni dei metadati.",
"TaskDownloadMissingSubtitles": "Scarica i sottotitoli mancanti",
- "TaskRefreshChannels": "Aggiorna i canali",
- "TaskCleanTranscodeDescription": "Cancella i file di transcode più vecchi di un giorno.",
- "TaskCleanTranscode": "Svuota la cartella del transcoding",
- "TaskUpdatePluginsDescription": "Scarica e installa gli aggiornamenti per i plugin che sono stati configurati per essere aggiornati contemporaneamente.",
- "TaskUpdatePlugins": "Aggiorna i Plugin",
- "TaskRefreshPeopleDescription": "Aggiorna i metadati per gli attori e registi nella tua libreria multimediale.",
- "TaskRefreshPeople": "Aggiornamento Persone",
+ "TaskRefreshChannels": "Aggiorna canali",
+ "TaskCleanTranscodeDescription": "Cancella i file di transcodifica più vecchi di un giorno.",
+ "TaskCleanTranscode": "Svuota la cartella della transcodifica",
+ "TaskUpdatePluginsDescription": "Scarica e installa gli aggiornamenti per i plugin configurati per l'aggiornamento automatico.",
+ "TaskUpdatePlugins": "Aggiorna i plugin",
+ "TaskRefreshPeopleDescription": "Aggiorna i metadati degli attori e registi nella tua libreria.",
+ "TaskRefreshPeople": "Aggiorna Persone",
"TaskCleanLogsDescription": "Rimuovi i file di log più vecchi di {0} giorni.",
"TaskCleanLogs": "Pulisci la cartella dei log",
- "TaskRefreshLibraryDescription": "Analizza la tua libreria multimediale per nuovi file e rinnova i metadati.",
- "TaskRefreshLibrary": "Scan Librerie",
- "TaskRefreshChapterImagesDescription": "Crea le thumbnail per i video che hanno capitoli.",
+ "TaskRefreshLibraryDescription": "Scansiona la libreria alla ricerca di nuovi file e aggiorna i metadati.",
+ "TaskRefreshLibrary": "Scansione della libreria",
+ "TaskRefreshChapterImagesDescription": "Crea le miniature per i video che hanno capitoli.",
"TaskRefreshChapterImages": "Estrai immagini capitolo",
"TaskCleanCacheDescription": "Cancella i file di cache non più necessari al sistema.",
- "TaskCleanCache": "Pulisci la directory della cache",
+ "TaskCleanCache": "Pulisci la cartella della cache",
"TasksChannelsCategory": "Canali su Internet",
"TasksApplicationCategory": "Applicazione",
"TasksLibraryCategory": "Libreria",
"TasksMaintenanceCategory": "Manutenzione",
"TaskCleanActivityLog": "Attività di Registro Completate",
- "TaskCleanActivityLogDescription": "Elimina gli inserimenti nel registro delle attività più vecchie dell’età configurata.",
+ "TaskCleanActivityLogDescription": "Elimina le voci del registro delle attività più vecchie dell’età configurata.",
"Undefined": "Non Definito",
"Forced": "Forzato",
"Default": "Predefinito",
- "TaskOptimizeDatabaseDescription": "Compatta Database e tronca spazi liberi. Eseguire questa azione dopo la scansione o dopo aver fatto altri cambiamenti inerenti il database potrebbe aumentarne la performance.",
- "TaskOptimizeDatabase": "Ottimizza Database",
+ "TaskOptimizeDatabaseDescription": "Compatta database e tronca spazi liberi. Eseguire questa azione dopo la scansione o dopo aver fatto altre modifiche inerenti il database potrebbe aumentarne le prestazioni.",
+ "TaskOptimizeDatabase": "Ottimizza database",
"TaskKeyframeExtractor": "Estrattore di Keyframe",
"TaskKeyframeExtractorDescription": "Estrae i keyframe dai video per creare migliori playlist HLS. Questa procedura potrebbe richiedere molto tempo.",
"External": "Esterno",
- "HearingImpaired": "con problemi di udito",
+ "HearingImpaired": "Non Udenti",
"TaskRefreshTrickplayImages": "Genera immagini Trickplay",
"TaskRefreshTrickplayImagesDescription": "Crea anteprime trickplay per i video nelle librerie abilitate.",
- "TaskCleanCollectionsAndPlaylists": "Ripulire le raccolte e le playlist",
- "TaskCleanCollectionsAndPlaylistsDescription": "Rimuove gli elementi dalle raccolte e dalle playlist che non esistono più.",
- "TaskAudioNormalization": "Normalizzazione Audio",
- "TaskAudioNormalizationDescription": "Scansione files per normalizzazione audio."
+ "TaskCleanCollectionsAndPlaylists": "Ripulire le collezioni e le playlist",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Rimuove gli elementi dalle collezioni e dalle playlist che non esistono più.",
+ "TaskAudioNormalization": "Normalizzazione dell'audio",
+ "TaskAudioNormalizationDescription": "Scansiona i file alla ricerca dei dati per la normalizzazione dell'audio."
}
diff --git a/Emby.Server.Implementations/Localization/Core/lv.json b/Emby.Server.Implementations/Localization/Core/lv.json
index 6e58ef834..78c3d0a40 100644
--- a/Emby.Server.Implementations/Localization/Core/lv.json
+++ b/Emby.Server.Implementations/Localization/Core/lv.json
@@ -17,7 +17,7 @@
"Inherit": "Pārmantot",
"AppDeviceValues": "Lietotne: {0}, Ierīce: {1}",
"VersionNumber": "Versija {0}",
- "ValueHasBeenAddedToLibrary": "{0} ir ticis pievienots jūsu multvides bibliotēkai",
+ "ValueHasBeenAddedToLibrary": "{0} tika pievienots jūsu multvides bibliotēkai",
"UserStoppedPlayingItemWithValues": "{0} ir beidzis atskaņot {1} uz {2}",
"UserStartedPlayingItemWithValues": "{0} atskaņo {1} uz {2}",
"UserPasswordChangedWithName": "Lietotāja {0} parole tika nomainīta",
@@ -76,7 +76,7 @@
"Genres": "Žanri",
"Folders": "Mapes",
"Favorites": "Izlase",
- "FailedLoginAttemptWithUserName": "Neizdevies ieiešanas mēģinājums no {0}",
+ "FailedLoginAttemptWithUserName": "Neveiksmīgs ielogošanos mēģinājums no {0}",
"DeviceOnlineWithName": "Savienojums ar {0} ir izveidots",
"DeviceOfflineWithName": "Savienojums ar {0} ir pārtraukts",
"Collections": "Kolekcijas",
@@ -95,7 +95,7 @@
"TaskRefreshChapterImages": "Izvilkt nodaļu attēlus",
"TasksApplicationCategory": "Lietotne",
"TasksLibraryCategory": "Bibliotēka",
- "TaskDownloadMissingSubtitlesDescription": "Internetā meklē trūkstošus subtitrus balstoties uz metadatu uzstādījumiem.",
+ "TaskDownloadMissingSubtitlesDescription": "Meklē trūkstošus subtitrus internēta balstoties uz metadatu uzstādījumiem.",
"TaskDownloadMissingSubtitles": "Lejupielādēt trūkstošos subtitrus",
"TaskRefreshChannelsDescription": "Atjauno interneta kanālu informāciju.",
"TaskRefreshChannels": "Atjaunot kanālus",
@@ -105,8 +105,8 @@
"TaskUpdatePlugins": "Atjaunot paplašinājumus",
"TaskRefreshPeopleDescription": "Atjauno metadatus aktieriem un direktoriem jūsu multivides bibliotēkā.",
"TaskRefreshPeople": "Atjaunot cilvēkus",
- "TaskCleanLogsDescription": "Nodzēš logdatnes, kas ir senākas par {0} dienām.",
- "TaskCleanLogs": "Iztīrīt logdatņu mapi",
+ "TaskCleanLogsDescription": "Nodzēš žurnāla ierakstus, kas ir senāki par {0} dienām.",
+ "TaskCleanLogs": "Iztīrīt žurnālu mapi",
"TaskRefreshLibraryDescription": "Skenē jūsu multivides bibliotēku, lai atrastu jaunas datnes, un atsvaidzina metadatus.",
"TaskRefreshLibrary": "Skenēt multivides bibliotēku",
"TaskRefreshChapterImagesDescription": "Izveido sīktēlus priekš video ar sadaļām.",
@@ -125,5 +125,9 @@
"TaskKeyframeExtractor": "Atslēgkadru ekstraktors",
"TaskKeyframeExtractorDescription": "Ekstraktē atslēgkadrus no video failiem lai izveidotu precīzākus HLS atskaņošanas sarakstus. Šis process var būt ilgs.",
"TaskRefreshTrickplayImages": "Ģenerēt partīšanas attēlus",
- "TaskRefreshTrickplayImagesDescription": "Izveido priekšskatījumus videoklipu pārtīšanai iespējotajās bibliotēkās."
+ "TaskRefreshTrickplayImagesDescription": "Izveido priekšskatījumus videoklipu pārtīšanai iespējotajās bibliotēkās.",
+ "TaskAudioNormalization": "Audio normalizācija",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Noņem elemēntus no kolekcijām un atskaņošanas sarakstiem, kuri vairs neeksistē.",
+ "TaskAudioNormalizationDescription": "Skanē failus priekš audio normālizācijas informācijas.",
+ "TaskCleanCollectionsAndPlaylists": "Notīrīt kolekcijas un atskaņošanas sarakstus"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ml.json b/Emby.Server.Implementations/Localization/Core/ml.json
index 28c7dd190..5c3449381 100644
--- a/Emby.Server.Implementations/Localization/Core/ml.json
+++ b/Emby.Server.Implementations/Localization/Core/ml.json
@@ -6,7 +6,7 @@
"ChapterNameValue": "അധ്യായം {0}",
"DeviceOfflineWithName": "{0} വിച്ഛേദിച്ചു",
"DeviceOnlineWithName": "{0} ബന്ധിപ്പിച്ചു",
- "FailedLoginAttemptWithUserName": "{0} - എന്നതിൽ നിന്നുള്ള പ്രവേശന ശ്രമം പരാജയപ്പെട്ടു",
+ "FailedLoginAttemptWithUserName": "{0}ൽ നിന്നുള്ള പ്രവേശന ശ്രമം പരാജയപ്പെട്ടു",
"Forced": "നിർബന്ധിച്ചു",
"HeaderFavoriteAlbums": "പ്രിയപ്പെട്ട ആൽബങ്ങൾ",
"HeaderFavoriteArtists": "പ്രിയപ്പെട്ട കലാകാരന്മാർ",
@@ -125,5 +125,9 @@
"TaskKeyframeExtractorDescription": "കൂടുതൽ കൃത്യമായ HLS പ്ലേലിസ്റ്റുകൾ സൃഷ്‌ടിക്കുന്നതിന് വീഡിയോ ഫയലുകളിൽ നിന്ന് കീഫ്രെയിമുകൾ എക്‌സ്‌ട്രാക്‌റ്റ് ചെയ്യുന്നു. ഈ പ്രവർത്തനം പൂർത്തിയാവാൻ കുറച്ചധികം സമയം എടുത്തേക്കാം.",
"TaskKeyframeExtractor": "കീഫ്രെയിം എക്സ്ട്രാക്റ്റർ",
"TaskCleanCollectionsAndPlaylistsDescription": "നിലവിലില്ലാത്ത ശേഖരങ്ങളിൽ നിന്നും പ്ലേലിസ്റ്റുകളിൽ നിന്നും ഇനങ്ങൾ നീക്കംചെയ്യുന്നു.",
- "TaskCleanCollectionsAndPlaylists": "ശേഖരങ്ങളും പ്ലേലിസ്റ്റുകളും വൃത്തിയാക്കുക"
+ "TaskCleanCollectionsAndPlaylists": "ശേഖരങ്ങളും പ്ലേലിസ്റ്റുകളും വൃത്തിയാക്കുക",
+ "TaskAudioNormalization": "സാധാരണ ശബ്ദ നിലയിലെത്തിലെത്തിക്കുക",
+ "TaskAudioNormalizationDescription": "സാധാരണ ശബ്ദ നിലയിലെത്തിലെത്തിക്കുന്ന ഡാറ്റയ്ക്കായി ഫയലുകൾ സ്കാൻ ചെയ്യുക.",
+ "TaskRefreshTrickplayImages": "ട്രിക്ക് പ്ലേ ചിത്രങ്ങൾ സൃഷ്ടിക്കുക",
+ "TaskRefreshTrickplayImagesDescription": "പ്രവർത്തനക്ഷമമാക്കിയ ലൈബ്രറികളിൽ വീഡിയോകൾക്കായി ട്രിക്ക്പ്ലേ പ്രിവ്യൂകൾ സൃഷ്ടിക്കുന്നു."
}
diff --git a/Emby.Server.Implementations/Localization/Core/nb.json b/Emby.Server.Implementations/Localization/Core/nb.json
index b6c15d871..b66818ddc 100644
--- a/Emby.Server.Implementations/Localization/Core/nb.json
+++ b/Emby.Server.Implementations/Localization/Core/nb.json
@@ -126,5 +126,9 @@
"External": "Ekstern",
"HearingImpaired": "Hørselshemmet",
"TaskRefreshTrickplayImages": "Generer Trickplay bilder",
- "TaskRefreshTrickplayImagesDescription": "Oppretter trickplay-forhåndsvisninger for videoer i aktiverte biblioteker."
+ "TaskRefreshTrickplayImagesDescription": "Oppretter trickplay-forhåndsvisninger for videoer i aktiverte biblioteker.",
+ "TaskCleanCollectionsAndPlaylists": "Rydd kolleksjoner og spillelister",
+ "TaskAudioNormalization": "Lyd Normalisering",
+ "TaskAudioNormalizationDescription": "Skan filer for lyd normaliserende data",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Fjerner elementer fra kolleksjoner og spillelister som ikke lengere finnes"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ro.json b/Emby.Server.Implementations/Localization/Core/ro.json
index 537a6d3f2..cd0120fc7 100644
--- a/Emby.Server.Implementations/Localization/Core/ro.json
+++ b/Emby.Server.Implementations/Localization/Core/ro.json
@@ -78,7 +78,7 @@
"Genres": "Genuri",
"Folders": "Dosare",
"Favorites": "Favorite",
- "FailedLoginAttemptWithUserName": "Încercare de conectare nereușită de la {0}",
+ "FailedLoginAttemptWithUserName": "Încercare de conectare eșuată pentru {0}",
"DeviceOnlineWithName": "{0} este conectat",
"DeviceOfflineWithName": "{0} s-a deconectat",
"Collections": "Colecții",
diff --git a/Emby.Server.Implementations/Localization/Core/sv.json b/Emby.Server.Implementations/Localization/Core/sv.json
index 2cf0a04e0..f40c4478a 100644
--- a/Emby.Server.Implementations/Localization/Core/sv.json
+++ b/Emby.Server.Implementations/Localization/Core/sv.json
@@ -127,7 +127,8 @@
"HearingImpaired": "Hörselskadad",
"TaskRefreshTrickplayImages": "Generera Trickplay-bilder",
"TaskRefreshTrickplayImagesDescription": "Skapar trickplay-förhandsvisningar för videor i aktiverade bibliotek.",
- "TaskCleanCollectionsAndPlaylists": "Rensa samlingar och spellistor",
+ "TaskCleanCollectionsAndPlaylists": "Rensa upp samlingar och spellistor",
"TaskAudioNormalization": "Ljudnormalisering",
- "TaskCleanCollectionsAndPlaylistsDescription": "Tar bort objekt från samlingar och spellistor som inte längre finns."
+ "TaskCleanCollectionsAndPlaylistsDescription": "Tar bort objekt från samlingar och spellistor som inte längre finns.",
+ "TaskAudioNormalizationDescription": "Skannar filer för ljudnormaliseringsdata."
}
diff --git a/Emby.Server.Implementations/Localization/Core/uz.json b/Emby.Server.Implementations/Localization/Core/uz.json
index 43935f224..a1b3035f3 100644
--- a/Emby.Server.Implementations/Localization/Core/uz.json
+++ b/Emby.Server.Implementations/Localization/Core/uz.json
@@ -8,5 +8,20 @@
"Channels": "Kanallar",
"Books": "Kitoblar",
"Artists": "Ijrochilar",
- "Albums": "Albomlar"
+ "Albums": "Albomlar",
+ "AuthenticationSucceededWithUserName": "{0} muvaffaqiyatli tasdiqlandi",
+ "AppDeviceValues": "Ilova: {0}, Qurilma: {1}",
+ "Application": "Ilova",
+ "CameraImageUploadedFrom": "{0}dan yangi kamera rasmi yuklandi",
+ "DeviceOnlineWithName": "{0} ulangan",
+ "ItemRemovedWithName": "{0} kutbxonadan o'chirildi",
+ "External": "Tashqi",
+ "FailedLoginAttemptWithUserName": "Muvafaqiyatsiz kirishlar soni {0}",
+ "Forced": "Majburiy",
+ "ChapterNameValue": "{0}chi bo'lim",
+ "DeviceOfflineWithName": "{0} aloqa uzildi",
+ "HeaderLiveTV": "Jonli TV",
+ "HeaderNextUp": "Keyingisi",
+ "ItemAddedWithName": "{0} kutbxonaga qo'shildi",
+ "LabelIpAddressValue": "IP manzil: {0}"
}
diff --git a/Emby.Server.Implementations/Localization/Core/vi.json b/Emby.Server.Implementations/Localization/Core/vi.json
index af9b54ad1..4bedfe3b2 100644
--- a/Emby.Server.Implementations/Localization/Core/vi.json
+++ b/Emby.Server.Implementations/Localization/Core/vi.json
@@ -103,11 +103,11 @@
"HeaderFavoriteEpisodes": "Tập Phim Yêu Thích",
"HeaderFavoriteArtists": "Nghệ Sĩ Yêu Thích",
"HeaderFavoriteAlbums": "Album Ưa Thích",
- "FailedLoginAttemptWithUserName": "Đăng nhập không thành công thử từ {0}",
+ "FailedLoginAttemptWithUserName": "Nỗ lực đăng nhập không thành công từ {0}",
"DeviceOnlineWithName": "{0} đã kết nối",
"DeviceOfflineWithName": "{0} đã ngắt kết nối",
"ChapterNameValue": "Phân Cảnh {0}",
- "Channels": "Các Kênh",
+ "Channels": "Kênh",
"CameraImageUploadedFrom": "Một hình ảnh máy ảnh mới đã được tải lên từ {0}",
"Books": "Sách",
"AuthenticationSucceededWithUserName": "{0} xác thực thành công",
@@ -127,5 +127,7 @@
"TaskRefreshTrickplayImages": "Tạo Ảnh Xem Trước Trickplay",
"TaskRefreshTrickplayImagesDescription": "Tạo bản xem trước trịckplay cho video trong thư viện đã bật.",
"TaskCleanCollectionsAndPlaylists": "Dọn dẹp bộ sưu tập và danh sách phát",
- "TaskCleanCollectionsAndPlaylistsDescription": "Xóa các mục khỏi bộ sưu tập và danh sách phát không còn tồn tại."
+ "TaskCleanCollectionsAndPlaylistsDescription": "Xóa các mục khỏi bộ sưu tập và danh sách phát không còn tồn tại.",
+ "TaskAudioNormalization": "Chuẩn Hóa Âm Thanh",
+ "TaskAudioNormalizationDescription": "Quét tập tin để tìm dữ liệu chuẩn hóa âm thanh."
}
diff --git a/Emby.Server.Implementations/Localization/Core/zh-TW.json b/Emby.Server.Implementations/Localization/Core/zh-TW.json
index 4f77eea3b..f06bbc591 100644
--- a/Emby.Server.Implementations/Localization/Core/zh-TW.json
+++ b/Emby.Server.Implementations/Localization/Core/zh-TW.json
@@ -127,5 +127,7 @@
"TaskRefreshTrickplayImages": "生成快轉縮圖",
"TaskRefreshTrickplayImagesDescription": "為啟用快轉縮圖的媒體庫生成快轉縮圖。",
"TaskCleanCollectionsAndPlaylists": "清理系列和播放清單",
- "TaskCleanCollectionsAndPlaylistsDescription": "清理系列和播放清單中已不存在的項目。"
+ "TaskCleanCollectionsAndPlaylistsDescription": "清理系列和播放清單中已不存在的項目。",
+ "TaskAudioNormalization": "音量標準化",
+ "TaskAudioNormalizationDescription": "掃描文件以找出音量標準化資料。"
}
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs
index 04d6ed0f2..7f3a8e291 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs
@@ -8,6 +8,7 @@ using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
+using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
@@ -69,7 +70,7 @@ public partial class AudioNormalizationTask : IScheduledTask
/// <inheritdoc />
public string Key => "AudioNormalization";
- [GeneratedRegex(@"I:\s+(.*?)\s+LUFS")]
+ [GeneratedRegex(@"^\s+I:\s+(.*?)\s+LUFS")]
private static partial Regex LUFSRegex();
/// <inheritdoc />
@@ -179,16 +180,17 @@ public partial class AudioNormalizationTask : IScheduledTask
}
using var reader = process.StandardError;
- var output = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
- cancellationToken.ThrowIfCancellationRequested();
- MatchCollection split = LUFSRegex().Matches(output);
-
- if (split.Count != 0)
+ await foreach (var line in reader.ReadAllLinesAsync(cancellationToken))
{
- return float.Parse(split[0].Groups[1].ValueSpan, CultureInfo.InvariantCulture.NumberFormat);
+ Match match = LUFSRegex().Match(line);
+
+ if (match.Success)
+ {
+ return float.Parse(match.Groups[1].ValueSpan, CultureInfo.InvariantCulture.NumberFormat);
+ }
}
- _logger.LogError("Failed to find LUFS value in output:\n{Output}", output);
+ _logger.LogError("Failed to find LUFS value in output");
return null;
}
}
diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs
index 10d5b4f97..3dda5fdee 100644
--- a/Emby.Server.Implementations/Session/SessionManager.cs
+++ b/Emby.Server.Implementations/Session/SessionManager.cs
@@ -1202,7 +1202,8 @@ namespace Emby.Server.Implementations.Session
new DtoOptions(false)
{
EnableImages = false
- })
+ },
+ user.DisplayMissingEpisodes)
.Where(i => !i.IsVirtualItem)
.SkipWhile(i => !i.Id.Equals(episode.Id))
.ToList();
diff --git a/Emby.Server.Implementations/TV/TVSeriesManager.cs b/Emby.Server.Implementations/TV/TVSeriesManager.cs
index 34c9e86f2..c1a615666 100644
--- a/Emby.Server.Implementations/TV/TVSeriesManager.cs
+++ b/Emby.Server.Implementations/TV/TVSeriesManager.cs
@@ -91,7 +91,7 @@ namespace Emby.Server.Implementations.TV
}
string? presentationUniqueKey = null;
- int? limit = null;
+ int? limit = request.Limit;
if (!request.SeriesId.IsNullOrEmpty())
{
if (_libraryManager.GetItemById(request.SeriesId.Value) is Series series)
diff --git a/Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandler.cs b/Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandler.cs
index 9b4e2182c..e425000cd 100644
--- a/Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandler.cs
+++ b/Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandler.cs
@@ -1,5 +1,6 @@
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
using MediaBrowser.Common.Configuration;
using Microsoft.AspNetCore.Authorization;
@@ -24,24 +25,31 @@ namespace Jellyfin.Api.Auth.FirstTimeSetupPolicy
/// <inheritdoc />
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, FirstTimeSetupRequirement requirement)
{
+ // Succeed if the startup wizard / first time setup is not complete
if (!_configurationManager.CommonConfiguration.IsStartupWizardCompleted)
{
context.Succeed(requirement);
}
- else if (requirement.RequireAdmin && !context.User.IsInRole(UserRoles.Administrator))
+
+ // Succeed if user is admin
+ else if (context.User.IsInRole(UserRoles.Administrator))
{
- context.Fail();
+ context.Succeed(requirement);
}
- else if (!requirement.RequireAdmin && context.User.IsInRole(UserRoles.Guest))
+
+ // Fail if admin is required and user is not admin
+ else if (requirement.RequireAdmin)
{
context.Fail();
}
- else
+
+ // Succeed if admin is not required and user is not guest
+ else if (context.User.IsInRole(UserRoles.User))
{
- // Any user-specific checks are handled in the DefaultAuthorizationHandler.
context.Succeed(requirement);
}
+ // Any user-specific checks are handled in the DefaultAuthorizationHandler.
return Task.CompletedTask;
}
}
diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs
index b4ce343be..4001a6add 100644
--- a/Jellyfin.Api/Controllers/ItemUpdateController.cs
+++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs
@@ -290,17 +290,35 @@ public class ItemUpdateController : BaseJellyfinApiController
{
foreach (var season in rseries.Children.OfType<Season>())
{
- season.OfficialRating = request.OfficialRating;
+ if (!season.LockedFields.Contains(MetadataField.OfficialRating))
+ {
+ season.OfficialRating = request.OfficialRating;
+ }
+
season.CustomRating = request.CustomRating;
- season.Tags = season.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray();
+
+ if (!season.LockedFields.Contains(MetadataField.Tags))
+ {
+ season.Tags = season.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray();
+ }
+
season.OnMetadataChanged();
await season.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
foreach (var ep in season.Children.OfType<Episode>())
{
- ep.OfficialRating = request.OfficialRating;
+ if (!ep.LockedFields.Contains(MetadataField.OfficialRating))
+ {
+ ep.OfficialRating = request.OfficialRating;
+ }
+
ep.CustomRating = request.CustomRating;
- ep.Tags = ep.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray();
+
+ if (!ep.LockedFields.Contains(MetadataField.Tags))
+ {
+ ep.Tags = ep.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray();
+ }
+
ep.OnMetadataChanged();
await ep.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
}
@@ -310,9 +328,18 @@ public class ItemUpdateController : BaseJellyfinApiController
{
foreach (var ep in season.Children.OfType<Episode>())
{
- ep.OfficialRating = request.OfficialRating;
+ if (!ep.LockedFields.Contains(MetadataField.OfficialRating))
+ {
+ ep.OfficialRating = request.OfficialRating;
+ }
+
ep.CustomRating = request.CustomRating;
- ep.Tags = ep.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray();
+
+ if (!ep.LockedFields.Contains(MetadataField.Tags))
+ {
+ ep.Tags = ep.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray();
+ }
+
ep.OnMetadataChanged();
await ep.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
}
@@ -321,9 +348,18 @@ public class ItemUpdateController : BaseJellyfinApiController
{
foreach (BaseItem track in album.Children)
{
- track.OfficialRating = request.OfficialRating;
+ if (!track.LockedFields.Contains(MetadataField.OfficialRating))
+ {
+ track.OfficialRating = request.OfficialRating;
+ }
+
track.CustomRating = request.CustomRating;
- track.Tags = track.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray();
+
+ if (!track.LockedFields.Contains(MetadataField.Tags))
+ {
+ track.Tags = track.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray();
+ }
+
track.OnMetadataChanged();
await track.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
}
diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs
index cd4a0a23b..d33634412 100644
--- a/Jellyfin.Api/Controllers/ItemsController.cs
+++ b/Jellyfin.Api/Controllers/ItemsController.cs
@@ -76,6 +76,7 @@ public class ItemsController : BaseJellyfinApiController
/// <param name="hasSpecialFeature">Optional filter by items with special features.</param>
/// <param name="hasTrailer">Optional filter by items with trailers.</param>
/// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param>
+ /// <param name="indexNumber">Optional filter by index number.</param>
/// <param name="parentIndexNumber">Optional filter by parent index number.</param>
/// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param>
/// <param name="isHd">Optional filter by items that are HD or not.</param>
@@ -165,6 +166,7 @@ public class ItemsController : BaseJellyfinApiController
[FromQuery] bool? hasSpecialFeature,
[FromQuery] bool? hasTrailer,
[FromQuery] Guid? adjacentTo,
+ [FromQuery] int? indexNumber,
[FromQuery] int? parentIndexNumber,
[FromQuery] bool? hasParentalRating,
[FromQuery] bool? isHd,
@@ -366,6 +368,7 @@ public class ItemsController : BaseJellyfinApiController
MinCommunityRating = minCommunityRating,
MinCriticRating = minCriticRating,
ParentId = parentId ?? Guid.Empty,
+ IndexNumber = indexNumber,
ParentIndexNumber = parentIndexNumber,
EnableTotalRecordCount = enableTotalRecordCount,
ExcludeItemIds = excludeItemIds,
@@ -717,6 +720,7 @@ public class ItemsController : BaseJellyfinApiController
hasSpecialFeature,
hasTrailer,
adjacentTo,
+ null,
parentIndexNumber,
hasParentalRating,
isHd,
diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs
index fb9f44d46..93c2393f3 100644
--- a/Jellyfin.Api/Controllers/LibraryStructureController.cs
+++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs
@@ -180,7 +180,21 @@ public class LibraryStructureController : BaseJellyfinApiController
// No need to start if scanning the library because it will handle it
if (refreshLibrary)
{
- await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false);
+ await _libraryManager.ValidateTopLibraryFolders(CancellationToken.None, true).ConfigureAwait(false);
+ var newLib = _libraryManager.GetUserRootFolder().Children.FirstOrDefault(f => f.Path.Equals(newPath, StringComparison.OrdinalIgnoreCase));
+ if (newLib is CollectionFolder folder)
+ {
+ foreach (var child in folder.GetPhysicalFolders())
+ {
+ await child.RefreshMetadata(CancellationToken.None).ConfigureAwait(false);
+ await child.ValidateChildren(new Progress<double>(), CancellationToken.None).ConfigureAwait(false);
+ }
+ }
+ else
+ {
+ // We don't know if this one can be validated individually, trigger a new validation
+ await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false);
+ }
}
else
{
@@ -319,7 +333,7 @@ public class LibraryStructureController : BaseJellyfinApiController
public ActionResult UpdateLibraryOptions(
[FromBody] UpdateLibraryOptionsDto request)
{
- var item = _libraryManager.GetItemById<CollectionFolder>(request.Id, User.GetUserId());
+ var item = _libraryManager.GetItemById<CollectionFolder>(request.Id);
if (item is null)
{
return NotFound();
diff --git a/Jellyfin.Api/Controllers/TrailersController.cs b/Jellyfin.Api/Controllers/TrailersController.cs
index 4fbaafa2a..d7d0cc454 100644
--- a/Jellyfin.Api/Controllers/TrailersController.cs
+++ b/Jellyfin.Api/Controllers/TrailersController.cs
@@ -215,6 +215,7 @@ public class TrailersController : BaseJellyfinApiController
hasSpecialFeature,
hasTrailer,
adjacentTo,
+ null,
parentIndexNumber,
hasParentalRating,
isHd,
diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs
index 68b4b6b8b..426402667 100644
--- a/Jellyfin.Api/Controllers/TvShowsController.cs
+++ b/Jellyfin.Api/Controllers/TvShowsController.cs
@@ -231,6 +231,7 @@ public class TvShowsController : BaseJellyfinApiController
var dtoOptions = new DtoOptions { Fields = fields }
.AddClientFields(User)
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+ var shouldIncludeMissingEpisodes = (user is not null && user.DisplayMissingEpisodes) || User.GetIsApiKey();
if (seasonId.HasValue) // Season id was supplied. Get episodes by season id.
{
@@ -240,7 +241,7 @@ public class TvShowsController : BaseJellyfinApiController
return NotFound("No season exists with Id " + seasonId);
}
- episodes = seasonItem.GetEpisodes(user, dtoOptions);
+ episodes = seasonItem.GetEpisodes(user, dtoOptions, shouldIncludeMissingEpisodes);
}
else if (season.HasValue) // Season number was supplied. Get episodes by season number
{
@@ -256,7 +257,7 @@ public class TvShowsController : BaseJellyfinApiController
episodes = seasonItem is null ?
new List<BaseItem>()
- : ((Season)seasonItem).GetEpisodes(user, dtoOptions);
+ : ((Season)seasonItem).GetEpisodes(user, dtoOptions, shouldIncludeMissingEpisodes);
}
else // No season number or season id was supplied. Returning all episodes.
{
@@ -265,7 +266,7 @@ public class TvShowsController : BaseJellyfinApiController
return NotFound("Series not found");
}
- episodes = series.GetEpisodes(user, dtoOptions).ToList();
+ episodes = series.GetEpisodes(user, dtoOptions, shouldIncludeMissingEpisodes).ToList();
}
// Filter after the fact in case the ui doesn't want them
diff --git a/Jellyfin.Api/Helpers/MediaInfoHelper.cs b/Jellyfin.Api/Helpers/MediaInfoHelper.cs
index 5faa7bc59..212d678a8 100644
--- a/Jellyfin.Api/Helpers/MediaInfoHelper.cs
+++ b/Jellyfin.Api/Helpers/MediaInfoHelper.cs
@@ -385,19 +385,6 @@ public class MediaInfoHelper
/// <returns>A <see cref="Task"/> containing the <see cref="LiveStreamResponse"/>.</returns>
public async Task<LiveStreamResponse> OpenMediaSource(HttpContext httpContext, LiveStreamRequest request)
{
- // Enforce more restrictive transcoding profile for LiveTV due to compatability reasons
- // Cap the MaxStreamingBitrate to 20Mbps, because we are unable to reliably probe source bitrate,
- // which will cause the client to request extremely high bitrate that may fail the player/encoder
- request.MaxStreamingBitrate = request.MaxStreamingBitrate > 20000000 ? 20000000 : request.MaxStreamingBitrate;
-
- if (request.DeviceProfile is not null)
- {
- // Remove all fmp4 transcoding profiles, because it causes playback error and/or A/V sync issues
- // Notably: Some channels won't play on FireFox and LG webOs
- // Some channels from HDHomerun will experience A/V sync issues
- request.DeviceProfile.TranscodingProfiles = request.DeviceProfile.TranscodingProfiles.Where(p => !string.Equals(p.Container, "mp4", StringComparison.OrdinalIgnoreCase)).ToArray();
- }
-
var result = await _mediaSourceManager.OpenLiveStream(request, CancellationToken.None).ConfigureAwait(false);
var profile = request.DeviceProfile;
diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs
index 6cd466da0..af4a9e689 100644
--- a/Jellyfin.Api/Helpers/StreamingHelpers.cs
+++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs
@@ -142,6 +142,20 @@ public static class StreamingHelpers
}
else
{
+ // Enforce more restrictive transcoding profile for LiveTV due to compatability reasons
+ // Cap the MaxStreamingBitrate to 30Mbps, because we are unable to reliably probe source bitrate,
+ // which will cause the client to request extremely high bitrate that may fail the player/encoder
+ streamingRequest.VideoBitRate = streamingRequest.VideoBitRate > 30000000 ? 30000000 : streamingRequest.VideoBitRate;
+
+ if (streamingRequest.SegmentContainer is not null)
+ {
+ // Remove all fmp4 transcoding profiles, because it causes playback error and/or A/V sync issues
+ // Notably: Some channels won't play on FireFox and LG webOS
+ // Some channels from HDHomerun will experience A/V sync issues
+ streamingRequest.SegmentContainer = "ts";
+ streamingRequest.VideoCodec = "h264";
+ }
+
var liveStreamInfo = await mediaSourceManager.GetLiveStreamWithDirectStreamProvider(streamingRequest.LiveStreamId, cancellationToken).ConfigureAwait(false);
mediaSource = liveStreamInfo.Item1;
state.DirectStreamProvider = liveStreamInfo.Item2;
diff --git a/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs b/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs
index 3d747f2ea..a88989840 100644
--- a/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs
+++ b/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs
@@ -16,21 +16,28 @@ public static class ServiceCollectionExtensions
/// Adds the <see cref="IDbContextFactory{TContext}"/> interface to the service collection with second level caching enabled.
/// </summary>
/// <param name="serviceCollection">An instance of the <see cref="IServiceCollection"/> interface.</param>
+ /// <param name="disableSecondLevelCache">Whether second level cache disabled..</param>
/// <returns>The updated service collection.</returns>
- public static IServiceCollection AddJellyfinDbContext(this IServiceCollection serviceCollection)
+ public static IServiceCollection AddJellyfinDbContext(this IServiceCollection serviceCollection, bool disableSecondLevelCache)
{
- serviceCollection.AddEFSecondLevelCache(options =>
- options.UseMemoryCacheProvider()
- .CacheAllQueries(CacheExpirationMode.Sliding, TimeSpan.FromMinutes(10))
- .UseCacheKeyPrefix("EF_")
- // Don't cache null values. Remove this optional setting if it's not necessary.
- .SkipCachingResults(result => result.Value is null or EFTableRows { RowsCount: 0 }));
+ if (!disableSecondLevelCache)
+ {
+ serviceCollection.AddEFSecondLevelCache(options =>
+ options.UseMemoryCacheProvider()
+ .CacheAllQueries(CacheExpirationMode.Sliding, TimeSpan.FromMinutes(10))
+ .UseCacheKeyPrefix("EF_")
+ // Don't cache null values. Remove this optional setting if it's not necessary.
+ .SkipCachingResults(result => result.Value is null or EFTableRows { RowsCount: 0 }));
+ }
serviceCollection.AddPooledDbContextFactory<JellyfinDbContext>((serviceProvider, opt) =>
{
var applicationPaths = serviceProvider.GetRequiredService<IApplicationPaths>();
- opt.UseSqlite($"Filename={Path.Combine(applicationPaths.DataPath, "jellyfin.db")}")
- .AddInterceptors(serviceProvider.GetRequiredService<SecondLevelCacheInterceptor>());
+ var dbOpt = opt.UseSqlite($"Filename={Path.Combine(applicationPaths.DataPath, "jellyfin.db")}");
+ if (!disableSecondLevelCache)
+ {
+ dbOpt.AddInterceptors(serviceProvider.GetRequiredService<SecondLevelCacheInterceptor>());
+ }
});
return serviceCollection;
diff --git a/Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs b/Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs
index 6b95770ed..858df6728 100644
--- a/Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs
+++ b/Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs
@@ -85,6 +85,6 @@ public static class WebHostBuilderExtensions
logger.LogInformation("Kestrel listening to unix socket {SocketPath}", socketPath);
}
})
- .UseStartup(_ => new Startup(appHost));
+ .UseStartup(_ => new Startup(appHost, startupConfig));
}
}
diff --git a/Jellyfin.Server/Migrations/MigrationRunner.cs b/Jellyfin.Server/Migrations/MigrationRunner.cs
index 44aa43044..81fecc9a1 100644
--- a/Jellyfin.Server/Migrations/MigrationRunner.cs
+++ b/Jellyfin.Server/Migrations/MigrationRunner.cs
@@ -44,7 +44,8 @@ namespace Jellyfin.Server.Migrations
typeof(Routines.FixPlaylistOwner),
typeof(Routines.MigrateRatingLevels),
typeof(Routines.AddDefaultCastReceivers),
- typeof(Routines.UpdateDefaultPluginRepository)
+ typeof(Routines.UpdateDefaultPluginRepository),
+ typeof(Routines.FixAudioData),
};
/// <summary>
diff --git a/Jellyfin.Server/Migrations/Routines/FixAudioData.cs b/Jellyfin.Server/Migrations/Routines/FixAudioData.cs
new file mode 100644
index 000000000..a20253369
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/FixAudioData.cs
@@ -0,0 +1,106 @@
+using System;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Model.Entities;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines
+{
+ /// <summary>
+ /// Fixes the data column of audio types to be deserializable.
+ /// </summary>
+ internal class FixAudioData : IMigrationRoutine
+ {
+ private const string DbFilename = "library.db";
+ private readonly ILogger<FixAudioData> _logger;
+ private readonly IServerApplicationPaths _applicationPaths;
+ private readonly IItemRepository _itemRepository;
+
+ public FixAudioData(
+ IServerApplicationPaths applicationPaths,
+ ILoggerFactory loggerFactory,
+ IItemRepository itemRepository)
+ {
+ _applicationPaths = applicationPaths;
+ _itemRepository = itemRepository;
+ _logger = loggerFactory.CreateLogger<FixAudioData>();
+ }
+
+ /// <inheritdoc/>
+ public Guid Id => Guid.Parse("{CF6FABC2-9FBE-4933-84A5-FFE52EF22A58}");
+
+ /// <inheritdoc/>
+ public string Name => "FixAudioData";
+
+ /// <inheritdoc/>
+ public bool PerformOnNewInstall => false;
+
+ /// <inheritdoc/>
+ public void Perform()
+ {
+ var dbPath = Path.Combine(_applicationPaths.DataPath, DbFilename);
+
+ // Back up the database before modifying any entries
+ for (int i = 1; ; i++)
+ {
+ var bakPath = string.Format(CultureInfo.InvariantCulture, "{0}.bak{1}", dbPath, i);
+ if (!File.Exists(bakPath))
+ {
+ try
+ {
+ _logger.LogInformation("Backing up {Library} to {BackupPath}", DbFilename, bakPath);
+ File.Copy(dbPath, bakPath);
+ _logger.LogInformation("{Library} backed up to {BackupPath}", DbFilename, bakPath);
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Cannot make a backup of {Library} at path {BackupPath}", DbFilename, bakPath);
+ throw;
+ }
+ }
+ }
+
+ _logger.LogInformation("Backfilling audio lyrics data to database.");
+ var startIndex = 0;
+ var records = _itemRepository.GetCount(new InternalItemsQuery
+ {
+ IncludeItemTypes = [BaseItemKind.Audio],
+ });
+
+ while (startIndex < records)
+ {
+ var results = _itemRepository.GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = [BaseItemKind.Audio],
+ StartIndex = startIndex,
+ Limit = 5000,
+ SkipDeserialization = true
+ })
+ .Cast<Audio>()
+ .ToList();
+
+ foreach (var audio in results)
+ {
+ var lyricMediaStreams = audio.GetMediaStreams().Where(s => s.Type == MediaStreamType.Lyric).Select(s => s.Path).ToList();
+ if (lyricMediaStreams.Count > 0)
+ {
+ audio.HasLyrics = true;
+ audio.LyricFiles = lyricMediaStreams;
+ }
+ }
+
+ _itemRepository.SaveItems(results, CancellationToken.None);
+ startIndex += results.Count;
+ _logger.LogInformation("Backfilled data for {UpdatedRecords} of {TotalRecords} audio records", startIndex, records);
+ }
+ }
+ }
+}
diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs
index e9fb3e4c2..2ff377403 100644
--- a/Jellyfin.Server/Startup.cs
+++ b/Jellyfin.Server/Startup.cs
@@ -40,15 +40,18 @@ namespace Jellyfin.Server
{
private readonly CoreAppHost _serverApplicationHost;
private readonly IServerConfigurationManager _serverConfigurationManager;
+ private readonly IConfiguration _startupConfig;
/// <summary>
/// Initializes a new instance of the <see cref="Startup" /> class.
/// </summary>
/// <param name="appHost">The server application host.</param>
- public Startup(CoreAppHost appHost)
+ /// <param name="startupConfig">The server startupConfig.</param>
+ public Startup(CoreAppHost appHost, IConfiguration startupConfig)
{
_serverApplicationHost = appHost;
_serverConfigurationManager = appHost.ConfigurationManager;
+ _startupConfig = startupConfig;
}
/// <summary>
@@ -67,7 +70,7 @@ namespace Jellyfin.Server
// TODO remove once this is fixed upstream https://github.com/dotnet/aspnetcore/issues/34371
services.AddSingleton<IActionResultExecutor<PhysicalFileResult>, SymlinkFollowingPhysicalFileResultExecutor>();
services.AddJellyfinApi(_serverApplicationHost.GetApiPluginAssemblies(), _serverConfigurationManager.GetNetworkConfiguration());
- services.AddJellyfinDbContext();
+ services.AddJellyfinDbContext(_startupConfig.GetSqliteSecondLevelCacheDisabled());
services.AddJellyfinApiSwagger();
// configure custom legacy authentication
diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs
index 22793206e..184bb4d68 100644
--- a/MediaBrowser.Controller/Entities/BaseItem.cs
+++ b/MediaBrowser.Controller/Entities/BaseItem.cs
@@ -752,9 +752,6 @@ namespace MediaBrowser.Controller.Entities
public virtual bool SupportsAncestors => true;
[JsonIgnore]
- public virtual bool StopRefreshIfLocalMetadataFound => true;
-
- [JsonIgnore]
protected virtual bool SupportsOwnedItems => !ParentId.IsEmpty() && IsFileProtocol;
[JsonIgnore]
diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs
index 0f2ec1f8f..fcb45e7e5 100644
--- a/MediaBrowser.Controller/Entities/Folder.cs
+++ b/MediaBrowser.Controller/Entities/Folder.cs
@@ -364,7 +364,7 @@ namespace MediaBrowser.Controller.Entities
if (IsFileProtocol)
{
- IEnumerable<BaseItem> nonCachedChildren;
+ IEnumerable<BaseItem> nonCachedChildren = [];
try
{
@@ -373,7 +373,6 @@ namespace MediaBrowser.Controller.Entities
catch (Exception ex)
{
Logger.LogError(ex, "Error retrieving children folder");
- return;
}
progress.Report(ProgressHelpers.RetrievedChildren);
diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
index 555dd050c..1461a3680 100644
--- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
+++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
@@ -51,6 +51,7 @@ namespace MediaBrowser.Controller.Entities
TrailerTypes = Array.Empty<TrailerType>();
VideoTypes = Array.Empty<VideoType>();
Years = Array.Empty<int>();
+ SkipDeserialization = false;
}
public InternalItemsQuery(User? user)
@@ -358,6 +359,8 @@ namespace MediaBrowser.Controller.Entities
public string? SeriesTimerId { get; set; }
+ public bool SkipDeserialization { get; set; }
+
public void SetUser(User user)
{
MaxParentalRating = user.MaxParentalAgeRating;
diff --git a/MediaBrowser.Controller/Entities/Movies/Movie.cs b/MediaBrowser.Controller/Entities/Movies/Movie.cs
index 81f6248fa..ede544eec 100644
--- a/MediaBrowser.Controller/Entities/Movies/Movie.cs
+++ b/MediaBrowser.Controller/Entities/Movies/Movie.cs
@@ -45,9 +45,6 @@ namespace MediaBrowser.Controller.Entities.Movies
set => TmdbCollectionName = value;
}
- [JsonIgnore]
- public override bool StopRefreshIfLocalMetadataFound => false;
-
public override double GetDefaultPrimaryImageAspectRatio()
{
// hack for tv plugins
diff --git a/MediaBrowser.Controller/Entities/TV/Season.cs b/MediaBrowser.Controller/Entities/TV/Season.cs
index c29cefc15..083f12746 100644
--- a/MediaBrowser.Controller/Entities/TV/Season.cs
+++ b/MediaBrowser.Controller/Entities/TV/Season.cs
@@ -159,7 +159,7 @@ namespace MediaBrowser.Controller.Entities.TV
Func<BaseItem, bool> filter = i => UserViewBuilder.Filter(i, user, query, UserDataManager, LibraryManager);
- var items = GetEpisodes(user, query.DtoOptions).Where(filter);
+ var items = GetEpisodes(user, query.DtoOptions, true).Where(filter);
return PostFilterAndSort(items, query, false);
}
@@ -169,30 +169,31 @@ namespace MediaBrowser.Controller.Entities.TV
/// </summary>
/// <param name="user">The user.</param>
/// <param name="options">The options to use.</param>
+ /// <param name="shouldIncludeMissingEpisodes">If missing episodes should be included.</param>
/// <returns>Set of episodes.</returns>
- public List<BaseItem> GetEpisodes(User user, DtoOptions options)
+ public List<BaseItem> GetEpisodes(User user, DtoOptions options, bool shouldIncludeMissingEpisodes)
{
- return GetEpisodes(Series, user, options);
+ return GetEpisodes(Series, user, options, shouldIncludeMissingEpisodes);
}
- public List<BaseItem> GetEpisodes(Series series, User user, DtoOptions options)
+ public List<BaseItem> GetEpisodes(Series series, User user, DtoOptions options, bool shouldIncludeMissingEpisodes)
{
- return GetEpisodes(series, user, null, options);
+ return GetEpisodes(series, user, null, options, shouldIncludeMissingEpisodes);
}
- public List<BaseItem> GetEpisodes(Series series, User user, IEnumerable<Episode> allSeriesEpisodes, DtoOptions options)
+ public List<BaseItem> GetEpisodes(Series series, User user, IEnumerable<Episode> allSeriesEpisodes, DtoOptions options, bool shouldIncludeMissingEpisodes)
{
- return series.GetSeasonEpisodes(this, user, allSeriesEpisodes, options);
+ return series.GetSeasonEpisodes(this, user, allSeriesEpisodes, options, shouldIncludeMissingEpisodes);
}
public List<BaseItem> GetEpisodes()
{
- return Series.GetSeasonEpisodes(this, null, null, new DtoOptions(true));
+ return Series.GetSeasonEpisodes(this, null, null, new DtoOptions(true), true);
}
public override List<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query)
{
- return GetEpisodes(user, new DtoOptions(true));
+ return GetEpisodes(user, new DtoOptions(true), true);
}
protected override bool GetBlockUnratedValue(User user)
diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs
index d200721b2..d704208cd 100644
--- a/MediaBrowser.Controller/Entities/TV/Series.cs
+++ b/MediaBrowser.Controller/Entities/TV/Series.cs
@@ -25,12 +25,9 @@ namespace MediaBrowser.Controller.Entities.TV
/// </summary>
public class Series : Folder, IHasTrailers, IHasDisplayOrder, IHasLookupInfo<SeriesInfo>, IMetadataContainer
{
- private readonly Dictionary<int, string> _seasonNames;
-
public Series()
{
AirDays = Array.Empty<DayOfWeek>();
- _seasonNames = new Dictionary<int, string>();
}
public DayOfWeek[] AirDays { get; set; }
@@ -72,9 +69,6 @@ namespace MediaBrowser.Controller.Entities.TV
/// <value>The status.</value>
public SeriesStatus? Status { get; set; }
- [JsonIgnore]
- public override bool StopRefreshIfLocalMetadataFound => false;
-
public override double GetDefaultPrimaryImageAspectRatio()
{
double value = 2;
@@ -212,26 +206,6 @@ namespace MediaBrowser.Controller.Entities.TV
return LibraryManager.GetItemList(query);
}
- public Dictionary<int, string> GetSeasonNames()
- {
- var newSeasons = Children.OfType<Season>()
- .Where(s => s.IndexNumber.HasValue)
- .Where(s => !_seasonNames.ContainsKey(s.IndexNumber.Value))
- .DistinctBy(s => s.IndexNumber);
-
- foreach (var season in newSeasons)
- {
- SetSeasonName(season.IndexNumber.Value, season.Name);
- }
-
- return _seasonNames;
- }
-
- public void SetSeasonName(int index, string name)
- {
- _seasonNames[index] = name;
- }
-
private void SetSeasonQueryOptions(InternalItemsQuery query, User user)
{
var seriesKey = GetUniqueSeriesKey(this);
@@ -276,7 +250,7 @@ namespace MediaBrowser.Controller.Entities.TV
return LibraryManager.GetItemsResult(query);
}
- public IEnumerable<BaseItem> GetEpisodes(User user, DtoOptions options)
+ public IEnumerable<BaseItem> GetEpisodes(User user, DtoOptions options, bool shouldIncludeMissingEpisodes)
{
var seriesKey = GetUniqueSeriesKey(this);
@@ -286,10 +260,10 @@ namespace MediaBrowser.Controller.Entities.TV
SeriesPresentationUniqueKey = seriesKey,
IncludeItemTypes = new[] { BaseItemKind.Episode, BaseItemKind.Season },
OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) },
- DtoOptions = options
+ DtoOptions = options,
};
- if (user is null || !user.DisplayMissingEpisodes)
+ if (!shouldIncludeMissingEpisodes)
{
query.IsMissing = false;
}
@@ -299,7 +273,7 @@ namespace MediaBrowser.Controller.Entities.TV
var allSeriesEpisodes = allItems.OfType<Episode>().ToList();
var allEpisodes = allItems.OfType<Season>()
- .SelectMany(i => i.GetEpisodes(this, user, allSeriesEpisodes, options))
+ .SelectMany(i => i.GetEpisodes(this, user, allSeriesEpisodes, options, shouldIncludeMissingEpisodes))
.Reverse();
// Specials could appear twice based on above - once in season 0, once in the aired season
@@ -311,8 +285,7 @@ namespace MediaBrowser.Controller.Entities.TV
public async Task RefreshAllMetadata(MetadataRefreshOptions refreshOptions, IProgress<double> progress, CancellationToken cancellationToken)
{
- // Refresh bottom up, children first, then the boxset
- // By then hopefully the movies within will have Tmdb collection values
+ // Refresh bottom up, seasons and episodes first, then the series
var items = GetRecursiveChildren();
var totalItems = items.Count;
@@ -375,7 +348,7 @@ namespace MediaBrowser.Controller.Entities.TV
await ProviderManager.RefreshSingleItem(this, refreshOptions, cancellationToken).ConfigureAwait(false);
}
- public List<BaseItem> GetSeasonEpisodes(Season parentSeason, User user, DtoOptions options)
+ public List<BaseItem> GetSeasonEpisodes(Season parentSeason, User user, DtoOptions options, bool shouldIncludeMissingEpisodes)
{
var queryFromSeries = ConfigurationManager.Configuration.DisplaySpecialsWithinSeasons;
@@ -392,24 +365,22 @@ namespace MediaBrowser.Controller.Entities.TV
OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) },
DtoOptions = options
};
- if (user is not null)
+
+ if (!shouldIncludeMissingEpisodes)
{
- if (!user.DisplayMissingEpisodes)
- {
- query.IsMissing = false;
- }
+ query.IsMissing = false;
}
var allItems = LibraryManager.GetItemList(query);
- return GetSeasonEpisodes(parentSeason, user, allItems, options);
+ return GetSeasonEpisodes(parentSeason, user, allItems, options, shouldIncludeMissingEpisodes);
}
- public List<BaseItem> GetSeasonEpisodes(Season parentSeason, User user, IEnumerable<BaseItem> allSeriesEpisodes, DtoOptions options)
+ public List<BaseItem> GetSeasonEpisodes(Season parentSeason, User user, IEnumerable<BaseItem> allSeriesEpisodes, DtoOptions options, bool shouldIncludeMissingEpisodes)
{
if (allSeriesEpisodes is null)
{
- return GetSeasonEpisodes(parentSeason, user, options);
+ return GetSeasonEpisodes(parentSeason, user, options, shouldIncludeMissingEpisodes);
}
var episodes = FilterEpisodesBySeason(allSeriesEpisodes, parentSeason, ConfigurationManager.Configuration.DisplaySpecialsWithinSeasons);
diff --git a/MediaBrowser.Controller/Entities/Trailer.cs b/MediaBrowser.Controller/Entities/Trailer.cs
index 1c558d419..81d50bbc1 100644
--- a/MediaBrowser.Controller/Entities/Trailer.cs
+++ b/MediaBrowser.Controller/Entities/Trailer.cs
@@ -23,9 +23,6 @@ namespace MediaBrowser.Controller.Entities
TrailerTypes = Array.Empty<TrailerType>();
}
- [JsonIgnore]
- public override bool StopRefreshIfLocalMetadataFound => false;
-
public TrailerType[] TrailerTypes { get; set; }
public override double GetDefaultPrimaryImageAspectRatio()
diff --git a/MediaBrowser.Controller/Extensions/ConfigurationExtensions.cs b/MediaBrowser.Controller/Extensions/ConfigurationExtensions.cs
index 6c58064ce..7dfda73bf 100644
--- a/MediaBrowser.Controller/Extensions/ConfigurationExtensions.cs
+++ b/MediaBrowser.Controller/Extensions/ConfigurationExtensions.cs
@@ -65,6 +65,11 @@ namespace MediaBrowser.Controller.Extensions
public const string SqliteCacheSizeKey = "sqlite:cacheSize";
/// <summary>
+ /// Disable second level cache of sqlite.
+ /// </summary>
+ public const string SqliteDisableSecondLevelCacheKey = "sqlite:disableSecondLevelCache";
+
+ /// <summary>
/// Gets a value indicating whether the application should host static web content from the <see cref="IConfiguration"/>.
/// </summary>
/// <param name="configuration">The configuration to retrieve the value from.</param>
@@ -128,5 +133,15 @@ namespace MediaBrowser.Controller.Extensions
/// <returns>The sqlite cache size.</returns>
public static int? GetSqliteCacheSize(this IConfiguration configuration)
=> configuration.GetValue<int?>(SqliteCacheSizeKey);
+
+ /// <summary>
+ /// Gets whether second level cache disabled from the <see cref="IConfiguration" />.
+ /// </summary>
+ /// <param name="configuration">The configuration to read the setting from.</param>
+ /// <returns>Whether second level cache disabled.</returns>
+ public static bool GetSqliteSecondLevelCacheDisabled(this IConfiguration configuration)
+ {
+ return configuration.GetValue<bool>(SqliteDisableSecondLevelCacheKey);
+ }
}
}
diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs
index 37703ceee..b802b7e6e 100644
--- a/MediaBrowser.Controller/Library/ILibraryManager.cs
+++ b/MediaBrowser.Controller/Library/ILibraryManager.cs
@@ -149,6 +149,14 @@ namespace MediaBrowser.Controller.Library
/// <returns>Task.</returns>
Task ValidateMediaLibrary(IProgress<double> progress, CancellationToken cancellationToken);
+ /// <summary>
+ /// Reloads the root media folder.
+ /// </summary>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <param name="removeRoot">Is remove the library itself allowed.</param>
+ /// <returns>Task.</returns>
+ Task ValidateTopLibraryFolders(CancellationToken cancellationToken, bool removeRoot = false);
+
Task UpdateImagesAsync(BaseItem item, bool forceUpdate = false);
/// <summary>
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
index 71670a963..a845c8124 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
@@ -1190,8 +1190,9 @@ namespace MediaBrowser.Controller.MediaEncoding
{
var tmpConcatPath = Path.Join(_configurationManager.GetTranscodePath(), state.MediaSource.Id + ".concat");
_mediaEncoder.GenerateConcatConfig(state.MediaSource, tmpConcatPath);
- arg.Append(" -f concat -safe 0 -i ")
- .Append(tmpConcatPath);
+ arg.Append(" -f concat -safe 0 -i \"")
+ .Append(tmpConcatPath)
+ .Append("\" ");
}
else
{
@@ -2321,7 +2322,11 @@ namespace MediaBrowser.Controller.MediaEncoding
if (request.VideoBitRate.HasValue
&& (!videoStream.BitRate.HasValue || videoStream.BitRate.Value > request.VideoBitRate.Value))
{
- return false;
+ // For LiveTV that has no bitrate, let's try copy if other conditions are met
+ if (string.IsNullOrWhiteSpace(request.LiveStreamId) || videoStream.BitRate.HasValue)
+ {
+ return false;
+ }
}
var maxBitDepth = state.GetRequestedVideoBitDepth(videoStream.Codec);
diff --git a/MediaBrowser.Controller/Providers/DirectoryService.cs b/MediaBrowser.Controller/Providers/DirectoryService.cs
index 7fe2f64af..56b07ebae 100644
--- a/MediaBrowser.Controller/Providers/DirectoryService.cs
+++ b/MediaBrowser.Controller/Providers/DirectoryService.cs
@@ -28,6 +28,22 @@ namespace MediaBrowser.Controller.Providers
return _cache.GetOrAdd(path, static (p, fileSystem) => fileSystem.GetFileSystemEntries(p).ToArray(), _fileSystem);
}
+ public List<FileSystemMetadata> GetDirectories(string path)
+ {
+ var list = new List<FileSystemMetadata>();
+ var items = GetFileSystemEntries(path);
+ for (var i = 0; i < items.Length; i++)
+ {
+ var item = items[i];
+ if (item.IsDirectory)
+ {
+ list.Add(item);
+ }
+ }
+
+ return list;
+ }
+
public List<FileSystemMetadata> GetFiles(string path)
{
var list = new List<FileSystemMetadata>();
diff --git a/MediaBrowser.Controller/Providers/IDirectoryService.cs b/MediaBrowser.Controller/Providers/IDirectoryService.cs
index 6d7550ab5..a3c06cde5 100644
--- a/MediaBrowser.Controller/Providers/IDirectoryService.cs
+++ b/MediaBrowser.Controller/Providers/IDirectoryService.cs
@@ -9,6 +9,8 @@ namespace MediaBrowser.Controller.Providers
{
FileSystemMetadata[] GetFileSystemEntries(string path);
+ List<FileSystemMetadata> GetDirectories(string path);
+
List<FileSystemMetadata> GetFiles(string path);
FileSystemMetadata? GetFile(string path);
diff --git a/MediaBrowser.Controller/Providers/IProviderManager.cs b/MediaBrowser.Controller/Providers/IProviderManager.cs
index eb5069b06..b52f16edc 100644
--- a/MediaBrowser.Controller/Providers/IProviderManager.cs
+++ b/MediaBrowser.Controller/Providers/IProviderManager.cs
@@ -141,6 +141,14 @@ namespace MediaBrowser.Controller.Providers
where T : BaseItem;
/// <summary>
+ /// Gets the metadata savers for the provided item.
+ /// </summary>
+ /// <param name="item">The item.</param>
+ /// <param name="libraryOptions">The library options.</param>
+ /// <returns>The metadata savers.</returns>
+ IEnumerable<IMetadataSaver> GetMetadataSavers(BaseItem item, LibraryOptions libraryOptions);
+
+ /// <summary>
/// Gets all metadata plugins.
/// </summary>
/// <returns>IEnumerable{MetadataPlugin}.</returns>
diff --git a/MediaBrowser.Controller/Providers/ItemInfo.cs b/MediaBrowser.Controller/Providers/ItemInfo.cs
index 3a97127ea..be3b25aee 100644
--- a/MediaBrowser.Controller/Providers/ItemInfo.cs
+++ b/MediaBrowser.Controller/Providers/ItemInfo.cs
@@ -11,6 +11,8 @@ namespace MediaBrowser.Controller.Providers
public ItemInfo(BaseItem item)
{
Path = item.Path;
+ ParentId = item.ParentId;
+ IndexNumber = item.IndexNumber;
ContainingFolderPath = item.ContainingFolderPath;
IsInMixedFolder = item.IsInMixedFolder;
@@ -27,6 +29,10 @@ namespace MediaBrowser.Controller.Providers
public string Path { get; set; }
+ public Guid ParentId { get; set; }
+
+ public int? IndexNumber { get; set; }
+
public string ContainingFolderPath { get; set; }
public VideoType VideoType { get; set; }
diff --git a/MediaBrowser.LocalMetadata/Images/EpisodeLocalImageProvider.cs b/MediaBrowser.LocalMetadata/Images/EpisodeLocalImageProvider.cs
index a8e2946f1..6a5e3bf04 100644
--- a/MediaBrowser.LocalMetadata/Images/EpisodeLocalImageProvider.cs
+++ b/MediaBrowser.LocalMetadata/Images/EpisodeLocalImageProvider.cs
@@ -38,19 +38,28 @@ namespace MediaBrowser.LocalMetadata.Images
}
var parentPathFiles = directoryService.GetFiles(parentPath);
+ var nameWithoutExtension = Path.GetFileNameWithoutExtension(item.Path.AsSpan()).ToString();
- var nameWithoutExtension = Path.GetFileNameWithoutExtension(item.Path.AsSpan());
+ var thumbName = string.Concat(nameWithoutExtension, "-thumb");
+ var images = GetImageFilesFromFolder(thumbName, parentPathFiles);
- return GetFilesFromParentFolder(nameWithoutExtension, parentPathFiles);
+ var metadataSubPath = directoryService.GetDirectories(parentPath).Where(d => d.Name.EndsWith("metadata", StringComparison.OrdinalIgnoreCase)).ToList();
+ foreach (var path in metadataSubPath)
+ {
+ var files = directoryService.GetFiles(path.FullName);
+ images.AddRange(GetImageFilesFromFolder(nameWithoutExtension, files));
+ }
+
+ return images;
}
- private List<LocalImageInfo> GetFilesFromParentFolder(ReadOnlySpan<char> filenameWithoutExtension, List<FileSystemMetadata> parentPathFiles)
+ private List<LocalImageInfo> GetImageFilesFromFolder(ReadOnlySpan<char> filenameWithoutExtension, List<FileSystemMetadata> filePaths)
{
var thumbName = string.Concat(filenameWithoutExtension, "-thumb");
var list = new List<LocalImageInfo>(1);
- foreach (var i in parentPathFiles)
+ foreach (var i in filePaths)
{
if (i.IsDirectory)
{
diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
index 3b7745b6a..914990558 100644
--- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
+++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
@@ -89,15 +89,28 @@ namespace MediaBrowser.MediaEncoding.Attachments
string outputPath,
CancellationToken cancellationToken)
{
- using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
+ var shouldExtractOneByOne = mediaSource.MediaAttachments.Any(a => a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase));
+ if (shouldExtractOneByOne)
{
- if (!Directory.Exists(outputPath))
+ var attachmentIndexes = mediaSource.MediaAttachments.Select(a => a.Index);
+ foreach (var i in attachmentIndexes)
{
- await ExtractAllAttachmentsInternal(
- _mediaEncoder.GetInputArgument(inputFile, mediaSource),
- outputPath,
- false,
- cancellationToken).ConfigureAwait(false);
+ var newName = Path.Join(outputPath, i.ToString(CultureInfo.InvariantCulture));
+ await ExtractAttachment(inputFile, mediaSource, i, newName, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ else
+ {
+ using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
+ {
+ if (!Directory.Exists(outputPath))
+ {
+ await ExtractAllAttachmentsInternal(
+ _mediaEncoder.GetInputArgument(inputFile, mediaSource),
+ outputPath,
+ false,
+ cancellationToken).ConfigureAwait(false);
+ }
}
}
}
diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
index 80ef6ecf7..f85510dac 100644
--- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
@@ -456,9 +456,9 @@ namespace MediaBrowser.MediaEncoding.Encoder
extraArgs += " -probesize " + ffmpegProbeSize;
}
- if (request.MediaSource.RequiredHttpHeaders.TryGetValue("user_agent", out var userAgent))
+ if (request.MediaSource.RequiredHttpHeaders.TryGetValue("User-Agent", out var userAgent))
{
- extraArgs += " -user_agent " + userAgent;
+ extraArgs += $" -user_agent \"{userAgent}\"";
}
if (request.MediaSource.Protocol == MediaProtocol.Rtsp)
diff --git a/MediaBrowser.Model/Entities/VirtualFolderInfo.cs b/MediaBrowser.Model/Entities/VirtualFolderInfo.cs
index 89bb72c3c..ea3df3726 100644
--- a/MediaBrowser.Model/Entities/VirtualFolderInfo.cs
+++ b/MediaBrowser.Model/Entities/VirtualFolderInfo.cs
@@ -2,8 +2,6 @@
#pragma warning disable CS1591
using System;
-using System.Text.Json.Serialization;
-using Jellyfin.Extensions.Json.Converters;
using MediaBrowser.Model.Configuration;
namespace MediaBrowser.Model.Entities
diff --git a/MediaBrowser.Providers/Manager/ImageSaver.cs b/MediaBrowser.Providers/Manager/ImageSaver.cs
index d82716831..e0677aa9f 100644
--- a/MediaBrowser.Providers/Manager/ImageSaver.cs
+++ b/MediaBrowser.Providers/Manager/ImageSaver.cs
@@ -100,8 +100,8 @@ namespace MediaBrowser.Providers.Manager
{
saveLocally = false;
- // If season is virtual under a physical series, save locally if using compatible convention
- if (item is Season season && _config.Configuration.ImageSavingConvention == ImageSavingConvention.Compatible)
+ // If season is virtual under a physical series, save locally
+ if (item is Season season)
{
var series = season.Series;
@@ -126,7 +126,11 @@ namespace MediaBrowser.Providers.Manager
var paths = GetSavePaths(item, type, imageIndex, mimeType, saveLocally);
- var retryPaths = GetSavePaths(item, type, imageIndex, mimeType, false);
+ string[] retryPaths = [];
+ if (saveLocally)
+ {
+ retryPaths = GetSavePaths(item, type, imageIndex, mimeType, false);
+ }
// If there are more than one output paths, the stream will need to be seekable
if (paths.Length > 1 && !source.CanSeek)
@@ -183,6 +187,13 @@ namespace MediaBrowser.Providers.Manager
try
{
_fileSystem.DeleteFile(currentPath);
+
+ // Remove containing directory if empty
+ var folder = Path.GetDirectoryName(currentPath);
+ if (!_fileSystem.GetFiles(folder).Any())
+ {
+ Directory.Delete(folder);
+ }
}
catch (FileNotFoundException)
{
@@ -374,6 +385,45 @@ namespace MediaBrowser.Providers.Manager
throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "Unable to determine image file extension from mime type {0}", mimeType));
}
+ if (string.Equals(extension, ".jpeg", StringComparison.OrdinalIgnoreCase))
+ {
+ extension = ".jpg";
+ }
+
+ extension = extension.ToLowerInvariant();
+
+ if (type == ImageType.Primary && saveLocally)
+ {
+ if (season is not null && season.IndexNumber.HasValue)
+ {
+ var seriesFolder = season.SeriesPath;
+
+ var seasonMarker = season.IndexNumber.Value == 0
+ ? "-specials"
+ : season.IndexNumber.Value.ToString("00", CultureInfo.InvariantCulture);
+
+ var imageFilename = "season" + seasonMarker + "-poster" + extension;
+
+ return Path.Combine(seriesFolder, imageFilename);
+ }
+ }
+
+ if (type == ImageType.Backdrop && saveLocally)
+ {
+ if (season is not null && season.IndexNumber.HasValue)
+ {
+ var seriesFolder = season.SeriesPath;
+
+ var seasonMarker = season.IndexNumber.Value == 0
+ ? "-specials"
+ : season.IndexNumber.Value.ToString("00", CultureInfo.InvariantCulture);
+
+ var imageFilename = "season" + seasonMarker + "-fanart" + extension;
+
+ return Path.Combine(seriesFolder, imageFilename);
+ }
+ }
+
if (type == ImageType.Thumb && saveLocally)
{
if (season is not null && season.IndexNumber.HasValue)
@@ -447,20 +497,12 @@ namespace MediaBrowser.Providers.Manager
break;
}
- if (string.Equals(extension, ".jpeg", StringComparison.OrdinalIgnoreCase))
- {
- extension = ".jpg";
- }
-
- extension = extension.ToLowerInvariant();
-
string path = null;
-
if (saveLocally)
{
if (type == ImageType.Primary && item is Episode)
{
- path = Path.Combine(Path.GetDirectoryName(item.Path), "metadata", filename + extension);
+ path = Path.Combine(Path.GetDirectoryName(item.Path), filename + "-thumb" + extension);
}
else if (item.IsInMixedFolder)
{
diff --git a/MediaBrowser.Providers/Manager/ItemImageProvider.cs b/MediaBrowser.Providers/Manager/ItemImageProvider.cs
index 1a5dbd7a5..bee420d95 100644
--- a/MediaBrowser.Providers/Manager/ItemImageProvider.cs
+++ b/MediaBrowser.Providers/Manager/ItemImageProvider.cs
@@ -371,12 +371,21 @@ namespace MediaBrowser.Providers.Manager
}
catch (FileNotFoundException)
{
- // nothing to do, already gone
+ // Nothing to do, already gone
}
catch (UnauthorizedAccessException ex)
{
_logger.LogWarning(ex, "Unable to delete {Image}", image.Path);
}
+ finally
+ {
+ // Always remove empty parent folder
+ var folder = Path.GetDirectoryName(image.Path);
+ if (Directory.Exists(folder) && !_fileSystem.GetFiles(folder).Any())
+ {
+ Directory.Delete(folder);
+ }
+ }
}
}
@@ -419,7 +428,8 @@ namespace MediaBrowser.Providers.Manager
var type = _singularImages[i];
var image = GetFirstLocalImageInfoByType(images, type);
- if (image is not null)
+ // Only use local images if we are not replacing and saving
+ if (image is not null && !(item.IsSaveLocalMetadataEnabled() && refreshOptions.ReplaceAllImages))
{
var currentImage = item.GetImageInfo(type, 0);
// if image file is stored with media, don't replace that later
diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs
index 234c5869a..7fd64809d 100644
--- a/MediaBrowser.Providers/Manager/MetadataService.cs
+++ b/MediaBrowser.Providers/Manager/MetadataService.cs
@@ -121,7 +121,8 @@ namespace MediaBrowser.Providers.Manager
var metadataResult = new MetadataResult<TItemType>
{
- Item = itemOfType
+ Item = itemOfType,
+ People = LibraryManager.GetPeople(item)
};
bool hasRefreshedMetadata = true;
@@ -153,7 +154,8 @@ namespace MediaBrowser.Providers.Manager
id.IsAutomated = refreshOptions.IsAutomated;
- var result = await RefreshWithProviders(metadataResult, id, refreshOptions, providers, ImageProvider, cancellationToken).ConfigureAwait(false);
+ var hasMetadataSavers = ProviderManager.GetMetadataSavers(item, libraryOptions).Any();
+ var result = await RefreshWithProviders(metadataResult, id, refreshOptions, providers, ImageProvider, hasMetadataSavers, cancellationToken).ConfigureAwait(false);
updateType |= result.UpdateType;
if (result.Failures > 0)
@@ -164,7 +166,7 @@ namespace MediaBrowser.Providers.Manager
}
// Next run remote image providers, but only if local image providers didn't throw an exception
- if (!localImagesFailed && refreshOptions.ImageRefreshMode != MetadataRefreshMode.ValidationOnly)
+ if (!localImagesFailed && refreshOptions.ImageRefreshMode > MetadataRefreshMode.ValidationOnly)
{
var providers = GetNonLocalImageProviders(item, allImageProviders, refreshOptions).ToList();
@@ -242,7 +244,7 @@ namespace MediaBrowser.Providers.Manager
protected async Task SaveItemAsync(MetadataResult<TItemType> result, ItemUpdateType reason, CancellationToken cancellationToken)
{
- if (result.Item.SupportsPeople && result.People is not null)
+ if (result.Item.SupportsPeople)
{
var baseItem = result.Item;
@@ -638,6 +640,7 @@ namespace MediaBrowser.Providers.Manager
MetadataRefreshOptions options,
ICollection<IMetadataProvider> providers,
ItemImageProvider imageService,
+ bool isSavingMetadata,
CancellationToken cancellationToken)
{
var refreshResult = new RefreshResult
@@ -655,102 +658,94 @@ namespace MediaBrowser.Providers.Manager
await RunCustomProvider(provider, item, logName, options, refreshResult, cancellationToken).ConfigureAwait(false);
}
+ if (item.IsLocked)
+ {
+ return refreshResult;
+ }
+
var temp = new MetadataResult<TItemType>
{
Item = CreateNew()
};
temp.Item.Path = item.Path;
+ temp.Item.Id = item.Id;
- // If replacing all metadata, run internet providers first
- if (options.ReplaceAllMetadata)
- {
- var remoteResult = await ExecuteRemoteProviders(temp, logName, id, providers.OfType<IRemoteMetadataProvider<TItemType, TIdType>>(), cancellationToken)
- .ConfigureAwait(false);
-
- refreshResult.UpdateType |= remoteResult.UpdateType;
- refreshResult.ErrorMessage = remoteResult.ErrorMessage;
- refreshResult.Failures += remoteResult.Failures;
- }
-
- var hasLocalMetadata = false;
var foundImageTypes = new List<ImageType>();
- foreach (var provider in providers.OfType<ILocalMetadataProvider<TItemType>>())
+ // Do not execute local providers if we are identifying or replacing with local metadata saving enabled
+ if (options.SearchResult is null && !(isSavingMetadata && options.ReplaceAllMetadata))
{
- var providerName = provider.GetType().Name;
- Logger.LogDebug("Running {Provider} for {Item}", providerName, logName);
-
- var itemInfo = new ItemInfo(item);
-
- try
+ foreach (var provider in providers.OfType<ILocalMetadataProvider<TItemType>>())
{
- var localItem = await provider.GetMetadata(itemInfo, options.DirectoryService, cancellationToken).ConfigureAwait(false);
+ var providerName = provider.GetType().Name;
+ Logger.LogDebug("Running {Provider} for {Item}", providerName, logName);
+
+ var itemInfo = new ItemInfo(item);
- if (localItem.HasMetadata)
+ try
{
- foreach (var remoteImage in localItem.RemoteImages)
+ var localItem = await provider.GetMetadata(itemInfo, options.DirectoryService, cancellationToken).ConfigureAwait(false);
+
+ if (localItem.HasMetadata)
{
- try
+ foreach (var remoteImage in localItem.RemoteImages)
{
- if (item.ImageInfos.Any(x => x.Type == remoteImage.Type)
- && !options.IsReplacingImage(remoteImage.Type))
+ try
{
- continue;
- }
+ if (item.ImageInfos.Any(x => x.Type == remoteImage.Type)
+ && !options.IsReplacingImage(remoteImage.Type))
+ {
+ continue;
+ }
- await ProviderManager.SaveImage(item, remoteImage.Url, remoteImage.Type, null, cancellationToken).ConfigureAwait(false);
- refreshResult.UpdateType |= ItemUpdateType.ImageUpdate;
+ await ProviderManager.SaveImage(item, remoteImage.Url, remoteImage.Type, null, cancellationToken).ConfigureAwait(false);
+ refreshResult.UpdateType |= ItemUpdateType.ImageUpdate;
- // remember imagetype that has just been downloaded
- foundImageTypes.Add(remoteImage.Type);
+ // remember imagetype that has just been downloaded
+ foundImageTypes.Add(remoteImage.Type);
+ }
+ catch (HttpRequestException ex)
+ {
+ Logger.LogError(ex, "Could not save {ImageType} image: {Url}", Enum.GetName(remoteImage.Type), remoteImage.Url);
+ }
}
- catch (HttpRequestException ex)
+
+ if (foundImageTypes.Count > 0)
{
- Logger.LogError(ex, "Could not save {ImageType} image: {Url}", Enum.GetName(remoteImage.Type), remoteImage.Url);
+ imageService.UpdateReplaceImages(options, foundImageTypes);
}
- }
- if (foundImageTypes.Count > 0)
- {
- imageService.UpdateReplaceImages(options, foundImageTypes);
- }
-
- if (imageService.MergeImages(item, localItem.Images, options))
- {
- refreshResult.UpdateType |= ItemUpdateType.ImageUpdate;
- }
+ if (imageService.MergeImages(item, localItem.Images, options))
+ {
+ refreshResult.UpdateType |= ItemUpdateType.ImageUpdate;
+ }
- MergeData(localItem, temp, Array.Empty<MetadataField>(), options.ReplaceAllMetadata, true);
- refreshResult.UpdateType |= ItemUpdateType.MetadataImport;
+ MergeData(localItem, temp, Array.Empty<MetadataField>(), false, true);
+ refreshResult.UpdateType |= ItemUpdateType.MetadataImport;
- // Only one local provider allowed per item
- if (item.IsLocked || localItem.Item.IsLocked || IsFullLocalMetadata(localItem.Item))
- {
- hasLocalMetadata = true;
+ break;
}
- break;
+ Logger.LogDebug("{Provider} returned no metadata for {Item}", providerName, logName);
}
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Error in {Provider}", provider.Name);
- Logger.LogDebug("{Provider} returned no metadata for {Item}", providerName, logName);
- }
- catch (OperationCanceledException)
- {
- throw;
- }
- catch (Exception ex)
- {
- Logger.LogError(ex, "Error in {Provider}", provider.Name);
-
- // If a local provider fails, consider that a failure
- refreshResult.ErrorMessage = ex.Message;
+ // If a local provider fails, consider that a failure
+ refreshResult.ErrorMessage = ex.Message;
+ }
}
}
- // Local metadata is king - if any is found don't run remote providers
- if (!options.ReplaceAllMetadata && (!hasLocalMetadata || options.MetadataRefreshMode == MetadataRefreshMode.FullRefresh || !item.StopRefreshIfLocalMetadataFound))
+ var isLocalLocked = temp.Item.IsLocked;
+ if (!isLocalLocked && (options.ReplaceAllMetadata || options.MetadataRefreshMode > MetadataRefreshMode.ValidationOnly))
{
- var remoteResult = await ExecuteRemoteProviders(temp, logName, id, providers.OfType<IRemoteMetadataProvider<TItemType, TIdType>>(), cancellationToken)
+ var remoteResult = await ExecuteRemoteProviders(temp, logName, false, id, providers.OfType<IRemoteMetadataProvider<TItemType, TIdType>>(), cancellationToken)
.ConfigureAwait(false);
refreshResult.UpdateType |= remoteResult.UpdateType;
@@ -762,19 +757,20 @@ namespace MediaBrowser.Providers.Manager
{
if (refreshResult.UpdateType > ItemUpdateType.None)
{
- if (hasLocalMetadata)
+ if (!options.RemoveOldMetadata)
+ {
+ // Add existing metadata to provider result if it does not exist there
+ MergeData(metadata, temp, Array.Empty<MetadataField>(), false, false);
+ }
+
+ if (isLocalLocked)
{
MergeData(temp, metadata, item.LockedFields, true, true);
}
else
{
- if (!options.RemoveOldMetadata)
- {
- MergeData(metadata, temp, Array.Empty<MetadataField>(), false, false);
- }
-
- // Will always replace all metadata when Scan for new and updated files is used. Else, follow the options.
- MergeData(temp, metadata, item.LockedFields, options.MetadataRefreshMode == MetadataRefreshMode.Default || options.ReplaceAllMetadata, false);
+ var shouldReplace = options.MetadataRefreshMode > MetadataRefreshMode.ValidationOnly || options.ReplaceAllMetadata;
+ MergeData(temp, metadata, item.LockedFields, shouldReplace, true);
}
}
}
@@ -787,16 +783,6 @@ namespace MediaBrowser.Providers.Manager
return refreshResult;
}
- protected virtual bool IsFullLocalMetadata(TItemType item)
- {
- if (string.IsNullOrWhiteSpace(item.Name))
- {
- return false;
- }
-
- return true;
- }
-
private async Task RunCustomProvider(ICustomMetadataProvider<TItemType> provider, TItemType item, string logName, MetadataRefreshOptions options, RefreshResult refreshResult, CancellationToken cancellationToken)
{
Logger.LogDebug("Running {Provider} for {Item}", provider.GetType().Name, logName);
@@ -821,7 +807,7 @@ namespace MediaBrowser.Providers.Manager
return new TItemType();
}
- private async Task<RefreshResult> ExecuteRemoteProviders(MetadataResult<TItemType> temp, string logName, TIdType id, IEnumerable<IRemoteMetadataProvider<TItemType, TIdType>> providers, CancellationToken cancellationToken)
+ private async Task<RefreshResult> ExecuteRemoteProviders(MetadataResult<TItemType> temp, string logName, bool replaceData, TIdType id, IEnumerable<IRemoteMetadataProvider<TItemType, TIdType>> providers, CancellationToken cancellationToken)
{
var refreshResult = new RefreshResult();
@@ -846,7 +832,7 @@ namespace MediaBrowser.Providers.Manager
{
result.Provider = provider.Name;
- MergeData(result, temp, Array.Empty<MetadataField>(), false, false);
+ MergeData(result, temp, Array.Empty<MetadataField>(), replaceData, false);
MergeNewData(temp.Item, id);
refreshResult.UpdateType |= ItemUpdateType.MetadataDownload;
@@ -949,11 +935,7 @@ namespace MediaBrowser.Providers.Manager
if (replaceData || string.IsNullOrEmpty(target.OriginalTitle))
{
- // Safeguard against incoming data having an empty name
- if (!string.IsNullOrWhiteSpace(source.OriginalTitle))
- {
- target.OriginalTitle = source.OriginalTitle;
- }
+ target.OriginalTitle = source.OriginalTitle;
}
if (replaceData || !target.CommunityRating.HasValue)
@@ -1016,7 +998,7 @@ namespace MediaBrowser.Providers.Manager
{
targetResult.People = sourceResult.People;
}
- else if (targetResult.People is not null && sourceResult.People is not null)
+ else if (sourceResult.People is not null && sourceResult.People.Count > 0)
{
MergePeople(sourceResult.People, targetResult.People);
}
@@ -1049,6 +1031,10 @@ namespace MediaBrowser.Providers.Manager
{
target.Studios = source.Studios;
}
+ else
+ {
+ target.Studios = target.Studios.Concat(source.Studios).Distinct().ToArray();
+ }
}
if (!lockedFields.Contains(MetadataField.Tags))
@@ -1057,6 +1043,10 @@ namespace MediaBrowser.Providers.Manager
{
target.Tags = source.Tags;
}
+ else
+ {
+ target.Tags = target.Tags.Concat(source.Tags).Distinct().ToArray();
+ }
}
if (!lockedFields.Contains(MetadataField.ProductionLocations))
@@ -1065,6 +1055,10 @@ namespace MediaBrowser.Providers.Manager
{
target.ProductionLocations = source.ProductionLocations;
}
+ else
+ {
+ target.ProductionLocations = target.ProductionLocations.Concat(source.ProductionLocations).Distinct().ToArray();
+ }
}
foreach (var id in source.ProviderIds)
@@ -1082,17 +1076,28 @@ namespace MediaBrowser.Providers.Manager
}
}
+ if (replaceData || !target.CriticRating.HasValue)
+ {
+ target.CriticRating = source.CriticRating;
+ }
+
+ if (replaceData || target.RemoteTrailers.Count == 0)
+ {
+ target.RemoteTrailers = source.RemoteTrailers;
+ }
+ else
+ {
+ target.RemoteTrailers = target.RemoteTrailers.Concat(source.RemoteTrailers).DistinctBy(t => t.Url).ToArray();
+ }
+
MergeAlbumArtist(source, target, replaceData);
- MergeCriticRating(source, target, replaceData);
- MergeTrailers(source, target, replaceData);
MergeVideoInfo(source, target, replaceData);
MergeDisplayOrder(source, target, replaceData);
if (replaceData || string.IsNullOrEmpty(target.ForcedSortName))
{
var forcedSortName = source.ForcedSortName;
-
- if (!string.IsNullOrWhiteSpace(forcedSortName))
+ if (!string.IsNullOrEmpty(forcedSortName))
{
target.ForcedSortName = forcedSortName;
}
@@ -1100,22 +1105,44 @@ namespace MediaBrowser.Providers.Manager
if (mergeMetadataSettings)
{
- target.LockedFields = source.LockedFields;
- target.IsLocked = source.IsLocked;
+ if (replaceData || !target.IsLocked)
+ {
+ target.IsLocked = target.IsLocked || source.IsLocked;
+ }
+
+ if (target.LockedFields.Length == 0)
+ {
+ target.LockedFields = source.LockedFields;
+ }
+ else
+ {
+ target.LockedFields = target.LockedFields.Concat(source.LockedFields).Distinct().ToArray();
+ }
- // Grab the value if it's there, but if not then don't overwrite with the default
if (source.DateCreated != default)
{
target.DateCreated = source.DateCreated;
}
- target.PreferredMetadataCountryCode = source.PreferredMetadataCountryCode;
- target.PreferredMetadataLanguage = source.PreferredMetadataLanguage;
+ if (replaceData || string.IsNullOrEmpty(target.PreferredMetadataCountryCode))
+ {
+ target.PreferredMetadataCountryCode = source.PreferredMetadataCountryCode;
+ }
+
+ if (replaceData || string.IsNullOrEmpty(target.PreferredMetadataLanguage))
+ {
+ target.PreferredMetadataLanguage = source.PreferredMetadataLanguage;
+ }
}
}
private static void MergePeople(List<PersonInfo> source, List<PersonInfo> target)
{
+ if (target is null)
+ {
+ target = new List<PersonInfo>();
+ }
+
foreach (var person in target)
{
var normalizedName = person.Name.RemoveDiacritics();
@@ -1144,7 +1171,6 @@ namespace MediaBrowser.Providers.Manager
if (replaceData || string.IsNullOrEmpty(targetHasDisplayOrder.DisplayOrder))
{
var displayOrder = sourceHasDisplayOrder.DisplayOrder;
-
if (!string.IsNullOrWhiteSpace(displayOrder))
{
targetHasDisplayOrder.DisplayOrder = displayOrder;
@@ -1162,22 +1188,10 @@ namespace MediaBrowser.Providers.Manager
{
targetHasAlbumArtist.AlbumArtists = sourceHasAlbumArtist.AlbumArtists;
}
- }
- }
-
- private static void MergeCriticRating(BaseItem source, BaseItem target, bool replaceData)
- {
- if (replaceData || !target.CriticRating.HasValue)
- {
- target.CriticRating = source.CriticRating;
- }
- }
-
- private static void MergeTrailers(BaseItem source, BaseItem target, bool replaceData)
- {
- if (replaceData || target.RemoteTrailers.Count == 0)
- {
- target.RemoteTrailers = source.RemoteTrailers;
+ else if (sourceHasAlbumArtist.AlbumArtists.Count > 0)
+ {
+ targetHasAlbumArtist.AlbumArtists = targetHasAlbumArtist.AlbumArtists.Concat(sourceHasAlbumArtist.AlbumArtists).Distinct().ToArray();
+ }
}
}
@@ -1185,7 +1199,7 @@ namespace MediaBrowser.Providers.Manager
{
if (source is Video sourceCast && target is Video targetCast)
{
- if (replaceData || targetCast.Video3DFormat is null)
+ if (replaceData || !targetCast.Video3DFormat.HasValue)
{
targetCast.Video3DFormat = sourceCast.Video3DFormat;
}
diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs
index 275f4028d..f2ca99da6 100644
--- a/MediaBrowser.Providers/Manager/ProviderManager.cs
+++ b/MediaBrowser.Providers/Manager/ProviderManager.cs
@@ -418,6 +418,12 @@ namespace MediaBrowser.Providers.Manager
return GetMetadataProvidersInternal<T>(item, libraryOptions, globalMetadataOptions, false, false);
}
+ /// <inheritdoc />
+ public IEnumerable<IMetadataSaver> GetMetadataSavers(BaseItem item, LibraryOptions libraryOptions)
+ {
+ return _savers.Where(i => IsSaverEnabledForItem(i, item, libraryOptions, ItemUpdateType.MetadataEdit, false));
+ }
+
private IEnumerable<IMetadataProvider<T>> GetMetadataProvidersInternal<T>(BaseItem item, LibraryOptions libraryOptions, MetadataOptions globalMetadataOptions, bool includeDisabled, bool forceEnableInternetMetadata)
where T : BaseItem
{
diff --git a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs
index 34681fac8..7ffe2f32a 100644
--- a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs
+++ b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -151,198 +152,212 @@ namespace MediaBrowser.Providers.MediaInfo
/// <param name="tryExtractEmbeddedLyrics">Whether to extract embedded lyrics to lrc file. </param>
private async Task FetchDataFromTags(Audio audio, Model.MediaInfo.MediaInfo mediaInfo, MetadataRefreshOptions options, bool tryExtractEmbeddedLyrics)
{
- using var file = TagLib.File.Create(audio.Path);
- var tagTypes = file.TagTypesOnDisk;
Tag? tags = null;
-
- if (tagTypes.HasFlag(TagTypes.Id3v2))
- {
- tags = file.GetTag(TagTypes.Id3v2);
- }
- else if (tagTypes.HasFlag(TagTypes.Ape))
- {
- tags = file.GetTag(TagTypes.Ape);
- }
- else if (tagTypes.HasFlag(TagTypes.FlacMetadata))
- {
- tags = file.GetTag(TagTypes.FlacMetadata);
- }
- else if (tagTypes.HasFlag(TagTypes.Apple))
+ try
{
- tags = file.GetTag(TagTypes.Apple);
- }
- else if (tagTypes.HasFlag(TagTypes.Xiph))
- {
- tags = file.GetTag(TagTypes.Xiph);
- }
- else if (tagTypes.HasFlag(TagTypes.AudibleMetadata))
- {
- tags = file.GetTag(TagTypes.AudibleMetadata);
+ using var file = TagLib.File.Create(audio.Path);
+ var tagTypes = file.TagTypesOnDisk;
+
+ if (tagTypes.HasFlag(TagTypes.Id3v2))
+ {
+ tags = file.GetTag(TagTypes.Id3v2);
+ }
+ else if (tagTypes.HasFlag(TagTypes.Ape))
+ {
+ tags = file.GetTag(TagTypes.Ape);
+ }
+ else if (tagTypes.HasFlag(TagTypes.FlacMetadata))
+ {
+ tags = file.GetTag(TagTypes.FlacMetadata);
+ }
+ else if (tagTypes.HasFlag(TagTypes.Apple))
+ {
+ tags = file.GetTag(TagTypes.Apple);
+ }
+ else if (tagTypes.HasFlag(TagTypes.Xiph))
+ {
+ tags = file.GetTag(TagTypes.Xiph);
+ }
+ else if (tagTypes.HasFlag(TagTypes.AudibleMetadata))
+ {
+ tags = file.GetTag(TagTypes.AudibleMetadata);
+ }
+ else if (tagTypes.HasFlag(TagTypes.Id3v1))
+ {
+ tags = file.GetTag(TagTypes.Id3v1);
+ }
}
- else if (tagTypes.HasFlag(TagTypes.Id3v1))
+ catch (Exception e)
{
- tags = file.GetTag(TagTypes.Id3v1);
+ _logger.LogWarning(e, "TagLib-Sharp does not support this audio");
}
- if (tags is not null)
+ tags ??= new TagLib.Id3v2.Tag();
+ tags.AlbumArtists ??= mediaInfo.AlbumArtists;
+ tags.Album ??= mediaInfo.Album;
+ tags.Title ??= mediaInfo.Name;
+ tags.Year = tags.Year == 0U ? Convert.ToUInt32(mediaInfo.ProductionYear, CultureInfo.InvariantCulture) : tags.Year;
+ tags.Performers ??= mediaInfo.Artists;
+ tags.Genres ??= mediaInfo.Genres;
+ tags.Track = tags.Track == 0U ? Convert.ToUInt32(mediaInfo.IndexNumber, CultureInfo.InvariantCulture) : tags.Track;
+ tags.Disc = tags.Disc == 0U ? Convert.ToUInt32(mediaInfo.ParentIndexNumber, CultureInfo.InvariantCulture) : tags.Disc;
+
+ if (audio.SupportsPeople && !audio.LockedFields.Contains(MetadataField.Cast))
{
- if (audio.SupportsPeople && !audio.LockedFields.Contains(MetadataField.Cast))
+ var people = new List<PersonInfo>();
+ var albumArtists = tags.AlbumArtists;
+ foreach (var albumArtist in albumArtists)
{
- var people = new List<PersonInfo>();
- var albumArtists = tags.AlbumArtists;
- foreach (var albumArtist in albumArtists)
+ if (!string.IsNullOrEmpty(albumArtist))
{
- if (!string.IsNullOrEmpty(albumArtist))
+ PeopleHelper.AddPerson(people, new PersonInfo
{
- PeopleHelper.AddPerson(people, new PersonInfo
- {
- Name = albumArtist,
- Type = PersonKind.AlbumArtist
- });
- }
+ Name = albumArtist,
+ Type = PersonKind.AlbumArtist
+ });
}
+ }
- var performers = tags.Performers;
- foreach (var performer in performers)
+ var performers = tags.Performers;
+ foreach (var performer in performers)
+ {
+ if (!string.IsNullOrEmpty(performer))
{
- if (!string.IsNullOrEmpty(performer))
+ PeopleHelper.AddPerson(people, new PersonInfo
{
- PeopleHelper.AddPerson(people, new PersonInfo
- {
- Name = performer,
- Type = PersonKind.Artist
- });
- }
+ Name = performer,
+ Type = PersonKind.Artist
+ });
}
+ }
- foreach (var composer in tags.Composers)
+ foreach (var composer in tags.Composers)
+ {
+ if (!string.IsNullOrEmpty(composer))
{
- if (!string.IsNullOrEmpty(composer))
+ PeopleHelper.AddPerson(people, new PersonInfo
{
- PeopleHelper.AddPerson(people, new PersonInfo
- {
- Name = composer,
- Type = PersonKind.Composer
- });
- }
- }
-
- _libraryManager.UpdatePeople(audio, people);
-
- if (options.ReplaceAllMetadata && performers.Length != 0)
- {
- audio.Artists = performers;
- }
- else if (!options.ReplaceAllMetadata
- && (audio.Artists is null || audio.Artists.Count == 0))
- {
- audio.Artists = performers;
- }
-
- if (albumArtists.Length == 0)
- {
- // Album artists not provided, fall back to performers (artists).
- albumArtists = performers;
- }
-
- if (options.ReplaceAllMetadata && albumArtists.Length != 0)
- {
- audio.AlbumArtists = albumArtists;
- }
- else if (!options.ReplaceAllMetadata
- && (audio.AlbumArtists is null || audio.AlbumArtists.Count == 0))
- {
- audio.AlbumArtists = albumArtists;
+ Name = composer,
+ Type = PersonKind.Composer
+ });
}
}
- if (!audio.LockedFields.Contains(MetadataField.Name) && !string.IsNullOrEmpty(tags.Title))
- {
- audio.Name = tags.Title;
- }
+ _libraryManager.UpdatePeople(audio, people);
- if (options.ReplaceAllMetadata)
+ if (options.ReplaceAllMetadata && performers.Length != 0)
{
- audio.Album = tags.Album;
- audio.IndexNumber = Convert.ToInt32(tags.Track);
- audio.ParentIndexNumber = Convert.ToInt32(tags.Disc);
+ audio.Artists = performers;
}
- else
+ else if (!options.ReplaceAllMetadata
+ && (audio.Artists is null || audio.Artists.Count == 0))
{
- audio.Album ??= tags.Album;
- audio.IndexNumber ??= Convert.ToInt32(tags.Track);
- audio.ParentIndexNumber ??= Convert.ToInt32(tags.Disc);
+ audio.Artists = performers;
}
- if (tags.Year != 0)
+ if (albumArtists.Length == 0)
{
- var year = Convert.ToInt32(tags.Year);
- audio.ProductionYear = year;
-
- if (!audio.PremiereDate.HasValue)
- {
- try
- {
- audio.PremiereDate = new DateTime(year, 01, 01);
- }
- catch (ArgumentOutOfRangeException ex)
- {
- _logger.LogError(ex, "Error parsing YEAR tag in {File}. '{TagValue}' is an invalid year.", audio.Path, tags.Year);
- }
- }
+ // Album artists not provided, fall back to performers (artists).
+ albumArtists = performers;
}
- if (!audio.LockedFields.Contains(MetadataField.Genres))
+ if (options.ReplaceAllMetadata && albumArtists.Length != 0)
{
- audio.Genres = options.ReplaceAllMetadata || audio.Genres == null || audio.Genres.Length == 0
- ? tags.Genres.Distinct(StringComparer.OrdinalIgnoreCase).ToArray()
- : audio.Genres;
+ audio.AlbumArtists = albumArtists;
}
-
- if (!double.IsNaN(tags.ReplayGainTrackGain))
+ else if (!options.ReplaceAllMetadata
+ && (audio.AlbumArtists is null || audio.AlbumArtists.Count == 0))
{
- audio.NormalizationGain = (float)tags.ReplayGainTrackGain;
- }
-
- if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out _))
- {
- audio.SetProviderId(MetadataProvider.MusicBrainzArtist, tags.MusicBrainzArtistId);
+ audio.AlbumArtists = albumArtists;
}
+ }
- if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbumArtist, out _))
- {
- audio.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, tags.MusicBrainzReleaseArtistId);
- }
+ if (!audio.LockedFields.Contains(MetadataField.Name) && !string.IsNullOrEmpty(tags.Title))
+ {
+ audio.Name = tags.Title;
+ }
- if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbum, out _))
- {
- audio.SetProviderId(MetadataProvider.MusicBrainzAlbum, tags.MusicBrainzReleaseId);
- }
+ if (options.ReplaceAllMetadata)
+ {
+ audio.Album = tags.Album;
+ audio.IndexNumber = Convert.ToInt32(tags.Track);
+ audio.ParentIndexNumber = Convert.ToInt32(tags.Disc);
+ }
+ else
+ {
+ audio.Album ??= tags.Album;
+ audio.IndexNumber ??= Convert.ToInt32(tags.Track);
+ audio.ParentIndexNumber ??= Convert.ToInt32(tags.Disc);
+ }
- if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzReleaseGroup, out _))
- {
- audio.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, tags.MusicBrainzReleaseGroupId);
- }
+ if (tags.Year != 0)
+ {
+ var year = Convert.ToInt32(tags.Year);
+ audio.ProductionYear = year;
- if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzTrack, out _))
+ if (!audio.PremiereDate.HasValue)
{
- // Fallback to ffprobe as TagLib incorrectly provides recording MBID in `tags.MusicBrainzTrackId`.
- // See https://github.com/mono/taglib-sharp/issues/304
- var trackMbId = mediaInfo.GetProviderId(MetadataProvider.MusicBrainzTrack);
- if (trackMbId is not null)
+ try
{
- audio.SetProviderId(MetadataProvider.MusicBrainzTrack, trackMbId);
+ audio.PremiereDate = new DateTime(year, 01, 01);
+ }
+ catch (ArgumentOutOfRangeException ex)
+ {
+ _logger.LogError(ex, "Error parsing YEAR tag in {File}. '{TagValue}' is an invalid year", audio.Path, tags.Year);
}
}
+ }
+
+ if (!audio.LockedFields.Contains(MetadataField.Genres))
+ {
+ audio.Genres = options.ReplaceAllMetadata || audio.Genres == null || audio.Genres.Length == 0
+ ? tags.Genres.Distinct(StringComparer.OrdinalIgnoreCase).ToArray()
+ : audio.Genres;
+ }
+
+ if (!double.IsNaN(tags.ReplayGainTrackGain))
+ {
+ audio.NormalizationGain = (float)tags.ReplayGainTrackGain;
+ }
+
+ if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out _))
+ {
+ audio.SetProviderId(MetadataProvider.MusicBrainzArtist, tags.MusicBrainzArtistId);
+ }
+
+ if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbumArtist, out _))
+ {
+ audio.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, tags.MusicBrainzReleaseArtistId);
+ }
+
+ if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbum, out _))
+ {
+ audio.SetProviderId(MetadataProvider.MusicBrainzAlbum, tags.MusicBrainzReleaseId);
+ }
+
+ if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzReleaseGroup, out _))
+ {
+ audio.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, tags.MusicBrainzReleaseGroupId);
+ }
- // Save extracted lyrics if they exist,
- // and if the audio doesn't yet have lyrics.
- if (!string.IsNullOrWhiteSpace(tags.Lyrics)
- && tryExtractEmbeddedLyrics)
+ if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzTrack, out _))
+ {
+ // Fallback to ffprobe as TagLib incorrectly provides recording MBID in `tags.MusicBrainzTrackId`.
+ // See https://github.com/mono/taglib-sharp/issues/304
+ var trackMbId = mediaInfo.GetProviderId(MetadataProvider.MusicBrainzTrack);
+ if (trackMbId is not null)
{
- await _lyricManager.SaveLyricAsync(audio, "lrc", tags.Lyrics).ConfigureAwait(false);
+ audio.SetProviderId(MetadataProvider.MusicBrainzTrack, trackMbId);
}
}
+
+ // Save extracted lyrics if they exist,
+ // and if the audio doesn't yet have lyrics.
+ if (!string.IsNullOrWhiteSpace(tags.Lyrics)
+ && tryExtractEmbeddedLyrics)
+ {
+ await _lyricManager.SaveLyricAsync(audio, "lrc", tags.Lyrics).ConfigureAwait(false);
+ }
}
private void AddExternalLyrics(
diff --git a/MediaBrowser.Providers/Movies/MovieMetadataService.cs b/MediaBrowser.Providers/Movies/MovieMetadataService.cs
index 984a3c122..8997ddc64 100644
--- a/MediaBrowser.Providers/Movies/MovieMetadataService.cs
+++ b/MediaBrowser.Providers/Movies/MovieMetadataService.cs
@@ -24,22 +24,6 @@ namespace MediaBrowser.Providers.Movies
}
/// <inheritdoc />
- protected override bool IsFullLocalMetadata(Movie item)
- {
- if (string.IsNullOrWhiteSpace(item.Overview))
- {
- return false;
- }
-
- if (!item.ProductionYear.HasValue)
- {
- return false;
- }
-
- return base.IsFullLocalMetadata(item);
- }
-
- /// <inheritdoc />
protected override void MergeData(MetadataResult<Movie> source, MetadataResult<Movie> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings)
{
base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
diff --git a/MediaBrowser.Providers/Movies/TrailerMetadataService.cs b/MediaBrowser.Providers/Movies/TrailerMetadataService.cs
index ad0c5aaa7..e77d2fa8a 100644
--- a/MediaBrowser.Providers/Movies/TrailerMetadataService.cs
+++ b/MediaBrowser.Providers/Movies/TrailerMetadataService.cs
@@ -1,5 +1,6 @@
#pragma warning disable CS1591
+using System.Linq;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
@@ -24,22 +25,6 @@ namespace MediaBrowser.Providers.Movies
}
/// <inheritdoc />
- protected override bool IsFullLocalMetadata(Trailer item)
- {
- if (string.IsNullOrWhiteSpace(item.Overview))
- {
- return false;
- }
-
- if (!item.ProductionYear.HasValue)
- {
- return false;
- }
-
- return base.IsFullLocalMetadata(item);
- }
-
- /// <inheritdoc />
protected override void MergeData(MetadataResult<Trailer> source, MetadataResult<Trailer> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings)
{
base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
@@ -48,6 +33,10 @@ namespace MediaBrowser.Providers.Movies
{
target.Item.TrailerTypes = source.Item.TrailerTypes;
}
+ else
+ {
+ target.Item.TrailerTypes = target.Item.TrailerTypes.Concat(source.Item.TrailerTypes).Distinct().ToArray();
+ }
}
}
}
diff --git a/MediaBrowser.Providers/Music/AlbumMetadataService.cs b/MediaBrowser.Providers/Music/AlbumMetadataService.cs
index e4f34776b..a39bd16ce 100644
--- a/MediaBrowser.Providers/Music/AlbumMetadataService.cs
+++ b/MediaBrowser.Providers/Music/AlbumMetadataService.cs
@@ -225,6 +225,10 @@ namespace MediaBrowser.Providers.Music
{
targetItem.Artists = sourceItem.Artists;
}
+ else
+ {
+ targetItem.Artists = targetItem.Artists.Concat(sourceItem.Artists).Distinct().ToArray();
+ }
if (replaceData || string.IsNullOrEmpty(targetItem.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist)))
{
diff --git a/MediaBrowser.Providers/Music/AudioMetadataService.cs b/MediaBrowser.Providers/Music/AudioMetadataService.cs
index a5b7cb895..7b25bc0e4 100644
--- a/MediaBrowser.Providers/Music/AudioMetadataService.cs
+++ b/MediaBrowser.Providers/Music/AudioMetadataService.cs
@@ -1,4 +1,5 @@
using System;
+using System.Linq;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Library;
@@ -60,6 +61,10 @@ namespace MediaBrowser.Providers.Music
{
targetItem.Artists = sourceItem.Artists;
}
+ else
+ {
+ targetItem.Artists = targetItem.Artists.Concat(sourceItem.Artists).Distinct().ToArray();
+ }
if (replaceData || string.IsNullOrEmpty(targetItem.Album))
{
diff --git a/MediaBrowser.Providers/Music/MusicVideoMetadataService.cs b/MediaBrowser.Providers/Music/MusicVideoMetadataService.cs
index b97b76630..24c4b5501 100644
--- a/MediaBrowser.Providers/Music/MusicVideoMetadataService.cs
+++ b/MediaBrowser.Providers/Music/MusicVideoMetadataService.cs
@@ -1,5 +1,6 @@
#pragma warning disable CS1591
+using System.Linq;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
@@ -45,6 +46,10 @@ namespace MediaBrowser.Providers.Music
{
targetItem.Artists = sourceItem.Artists;
}
+ else
+ {
+ targetItem.Artists = targetItem.Artists.Concat(sourceItem.Artists).Distinct().ToArray();
+ }
}
}
}
diff --git a/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs b/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs
index 1bd000a48..43889bfbf 100644
--- a/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs
+++ b/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs
@@ -1,6 +1,7 @@
#pragma warning disable CS1591
using System.Collections.Generic;
+using System.Linq;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
@@ -49,8 +50,24 @@ namespace MediaBrowser.Providers.Playlists
if (mergeMetadataSettings)
{
targetItem.PlaylistMediaType = sourceItem.PlaylistMediaType;
- targetItem.LinkedChildren = sourceItem.LinkedChildren;
- targetItem.Shares = sourceItem.Shares;
+
+ if (replaceData || targetItem.LinkedChildren.Length == 0)
+ {
+ targetItem.LinkedChildren = sourceItem.LinkedChildren;
+ }
+ else
+ {
+ targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).Distinct().ToArray();
+ }
+
+ if (replaceData || targetItem.Shares.Count == 0)
+ {
+ targetItem.Shares = sourceItem.Shares;
+ }
+ else
+ {
+ targetItem.Shares = sourceItem.Shares.Concat(targetItem.Shares).DistinctBy(s => s.UserId).ToArray();
+ }
}
}
}
diff --git a/MediaBrowser.Providers/TV/SeriesMetadataService.cs b/MediaBrowser.Providers/TV/SeriesMetadataService.cs
index 2d0bd60b9..2389bce57 100644
--- a/MediaBrowser.Providers/TV/SeriesMetadataService.cs
+++ b/MediaBrowser.Providers/TV/SeriesMetadataService.cs
@@ -62,23 +62,7 @@ namespace MediaBrowser.Providers.TV
RemoveObsoleteEpisodes(item);
RemoveObsoleteSeasons(item);
- await UpdateAndCreateSeasonsAsync(item, cancellationToken).ConfigureAwait(false);
- }
-
- /// <inheritdoc />
- protected override bool IsFullLocalMetadata(Series item)
- {
- if (string.IsNullOrWhiteSpace(item.Overview))
- {
- return false;
- }
-
- if (!item.ProductionYear.HasValue)
- {
- return false;
- }
-
- return base.IsFullLocalMetadata(item);
+ await CreateSeasonsAsync(item, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
@@ -88,24 +72,6 @@ namespace MediaBrowser.Providers.TV
var sourceItem = source.Item;
var targetItem = target.Item;
- var sourceSeasonNames = sourceItem.GetSeasonNames();
- var targetSeasonNames = targetItem.GetSeasonNames();
-
- if (replaceData)
- {
- foreach (var (number, name) in sourceSeasonNames)
- {
- targetItem.SetSeasonName(number, name);
- }
- }
- else
- {
- var newSeasons = sourceSeasonNames.Where(s => !targetSeasonNames.ContainsKey(s.Key));
- foreach (var (number, name) in newSeasons)
- {
- targetItem.SetSeasonName(number, name);
- }
- }
if (replaceData || string.IsNullOrEmpty(targetItem.AirTime))
{
@@ -153,7 +119,8 @@ namespace MediaBrowser.Providers.TV
virtualSeason,
new DeleteOptions
{
- DeleteFileLocation = true
+ // Internal metadata paths are removed regardless of this.
+ DeleteFileLocation = false
},
false);
}
@@ -162,7 +129,7 @@ namespace MediaBrowser.Providers.TV
private void RemoveObsoleteEpisodes(Series series)
{
- var episodes = series.GetEpisodes(null, new DtoOptions()).OfType<Episode>().ToList();
+ var episodes = series.GetEpisodes(null, new DtoOptions(), true).OfType<Episode>().ToList();
var numberOfEpisodes = episodes.Count;
// TODO: O(n^2), but can it be done faster without overcomplicating it?
for (var i = 0; i < numberOfEpisodes; i++)
@@ -210,7 +177,8 @@ namespace MediaBrowser.Providers.TV
episode,
new DeleteOptions
{
- DeleteFileLocation = true
+ // Internal metadata paths are removed regardless of this.
+ DeleteFileLocation = false
},
false);
}
@@ -218,14 +186,12 @@ namespace MediaBrowser.Providers.TV
/// <summary>
/// Creates seasons for all episodes if they don't exist.
/// If no season number can be determined, a dummy season will be created.
- /// Updates seasons names.
/// </summary>
/// <param name="series">The series.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The async task.</returns>
- private async Task UpdateAndCreateSeasonsAsync(Series series, CancellationToken cancellationToken)
+ private async Task CreateSeasonsAsync(Series series, CancellationToken cancellationToken)
{
- var seasonNames = series.GetSeasonNames();
var seriesChildren = series.GetRecursiveChildren(i => i is Episode || i is Season);
var seasons = seriesChildren.OfType<Season>().ToList();
var uniqueSeasonNumbers = seriesChildren
@@ -237,23 +203,12 @@ namespace MediaBrowser.Providers.TV
foreach (var seasonNumber in uniqueSeasonNumbers)
{
// Null season numbers will have a 'dummy' season created because seasons are always required.
- var existingSeason = seasons.FirstOrDefault(i => i.IndexNumber == seasonNumber);
-
- if (!seasonNumber.HasValue || !seasonNames.TryGetValue(seasonNumber.Value, out var seasonName))
- {
- seasonName = GetValidSeasonNameForSeries(series, null, seasonNumber);
- }
-
- if (existingSeason is null)
+ if (!seasons.Any(i => i.IndexNumber == seasonNumber))
{
+ var seasonName = GetValidSeasonNameForSeries(series, null, seasonNumber);
var season = await CreateSeasonAsync(series, seasonName, seasonNumber, cancellationToken).ConfigureAwait(false);
series.AddChild(season);
}
- else if (!existingSeason.LockedFields.Contains(MetadataField.Name) && !string.Equals(existingSeason.Name, seasonName, StringComparison.Ordinal))
- {
- existingSeason.Name = seasonName;
- await existingSeason.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false);
- }
}
}
diff --git a/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs
index 3b551acec..d99e11bcd 100644
--- a/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs
+++ b/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs
@@ -100,19 +100,10 @@ namespace MediaBrowser.XbmcMetadata.Parsers
break;
}
+ // Season names are processed by SeriesNfoSeasonParser
case "namedseason":
- {
- var parsed = int.TryParse(reader.GetAttribute("number"), NumberStyles.Integer, CultureInfo.InvariantCulture, out var seasonNumber);
- var name = reader.ReadElementContentAsString();
-
- if (!string.IsNullOrWhiteSpace(name) && parsed)
- {
- item.SetSeasonName(seasonNumber, name);
- }
-
- break;
- }
-
+ reader.Skip();
+ break;
default:
base.FetchDataFromXmlNode(reader, itemResult);
break;
diff --git a/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoSeasonParser.cs b/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoSeasonParser.cs
new file mode 100644
index 000000000..44ca3f472
--- /dev/null
+++ b/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoSeasonParser.cs
@@ -0,0 +1,60 @@
+using System.Globalization;
+using System.Xml;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.XbmcMetadata.Parsers
+{
+ /// <summary>
+ /// NFO parser for seasons based on series NFO.
+ /// </summary>
+ public class SeriesNfoSeasonParser : BaseNfoParser<Season>
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SeriesNfoSeasonParser"/> class.
+ /// </summary>
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ /// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param>
+ /// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param>
+ public SeriesNfoSeasonParser(
+ ILogger logger,
+ IConfigurationManager config,
+ IProviderManager providerManager,
+ IUserManager userManager,
+ IUserDataManager userDataManager,
+ IDirectoryService directoryService)
+ : base(logger, config, providerManager, userManager, userDataManager, directoryService)
+ {
+ }
+
+ /// <inheritdoc />
+ protected override bool SupportsUrlAfterClosingXmlTag => true;
+
+ /// <inheritdoc />
+ protected override void FetchDataFromXmlNode(XmlReader reader, MetadataResult<Season> itemResult)
+ {
+ var item = itemResult.Item;
+
+ if (reader.Name == "namedseason")
+ {
+ var parsed = int.TryParse(reader.GetAttribute("number"), NumberStyles.Integer, CultureInfo.InvariantCulture, out var seasonNumber);
+ var name = reader.ReadElementContentAsString();
+
+ if (parsed && !string.IsNullOrWhiteSpace(name) && item.IndexNumber.HasValue && seasonNumber == item.IndexNumber.Value)
+ {
+ item.Name = name;
+ }
+ }
+ else
+ {
+ reader.Skip();
+ }
+ }
+ }
+}
diff --git a/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs b/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs
index 9b4e1731d..22c065b5d 100644
--- a/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs
+++ b/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs
@@ -42,7 +42,10 @@ namespace MediaBrowser.XbmcMetadata.Providers
try
{
- result.Item = new T();
+ result.Item = new T
+ {
+ IndexNumber = info.IndexNumber
+ };
Fetch(result, path, cancellationToken);
result.HasMetadata = true;
diff --git a/MediaBrowser.XbmcMetadata/Providers/SeriesNfoSeasonProvider.cs b/MediaBrowser.XbmcMetadata/Providers/SeriesNfoSeasonProvider.cs
new file mode 100644
index 000000000..b141b7afb
--- /dev/null
+++ b/MediaBrowser.XbmcMetadata/Providers/SeriesNfoSeasonProvider.cs
@@ -0,0 +1,89 @@
+using System.IO;
+using System.Threading;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.IO;
+using MediaBrowser.XbmcMetadata.Parsers;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.XbmcMetadata.Providers
+{
+ /// <summary>
+ /// NFO provider for seasons based on series NFO.
+ /// </summary>
+ public class SeriesNfoSeasonProvider : BaseNfoProvider<Season>
+ {
+ private readonly ILogger<SeriesNfoSeasonProvider> _logger;
+ private readonly IConfigurationManager _config;
+ private readonly IProviderManager _providerManager;
+ private readonly IUserManager _userManager;
+ private readonly IUserDataManager _userDataManager;
+ private readonly IDirectoryService _directoryService;
+ private readonly ILibraryManager _libraryManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SeriesNfoSeasonProvider"/> class.
+ /// </summary>
+ /// <param name="logger">Instance of the <see cref="ILogger{SeasonFromSeriesNfoProvider}"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ /// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param>
+ /// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ public SeriesNfoSeasonProvider(
+ ILogger<SeriesNfoSeasonProvider> logger,
+ IFileSystem fileSystem,
+ IConfigurationManager config,
+ IProviderManager providerManager,
+ IUserManager userManager,
+ IUserDataManager userDataManager,
+ IDirectoryService directoryService,
+ ILibraryManager libraryManager)
+ : base(fileSystem)
+ {
+ _logger = logger;
+ _config = config;
+ _providerManager = providerManager;
+ _userManager = userManager;
+ _userDataManager = userDataManager;
+ _directoryService = directoryService;
+ _libraryManager = libraryManager;
+ }
+
+ /// <inheritdoc />
+ protected override void Fetch(MetadataResult<Season> result, string path, CancellationToken cancellationToken)
+ {
+ new SeriesNfoSeasonParser(_logger, _config, _providerManager, _userManager, _userDataManager, _directoryService).Fetch(result, path, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ protected override FileSystemMetadata? GetXmlFile(ItemInfo info, IDirectoryService directoryService)
+ {
+ var seasonPath = info.Path;
+ if (seasonPath is not null)
+ {
+ var path = Path.Combine(seasonPath, "tvshow.nfo");
+ if (Path.Exists(path))
+ {
+ return directoryService.GetFile(path);
+ }
+ }
+
+ var seriesPath = _libraryManager.GetItemById(info.ParentId)?.Path;
+ if (seriesPath is not null)
+ {
+ var path = Path.Combine(seriesPath, "tvshow.nfo");
+ if (Path.Exists(path))
+ {
+ return directoryService.GetFile(path);
+ }
+ }
+
+ return null;
+ }
+ }
+}
diff --git a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs
index b25cfc83f..a547779de 100644
--- a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs
+++ b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs
@@ -825,7 +825,7 @@ namespace MediaBrowser.XbmcMetadata.Savers
private string GetOutputTrailerUrl(string url)
{
// This is what xbmc expects
- return url.Replace(YouTubeWatchUrl, "plugin://plugin.video.youtube/?action=play_video&videoid=", StringComparison.OrdinalIgnoreCase);
+ return url.Replace(YouTubeWatchUrl, "plugin://plugin.video.youtube/play/?video_id=", StringComparison.OrdinalIgnoreCase);
}
private void AddImages(BaseItem item, XmlWriter writer, ILibraryManager libraryManager)
diff --git a/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs
index 8fa22fad9..bc344d87e 100644
--- a/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs
+++ b/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs
@@ -45,27 +45,24 @@ namespace MediaBrowser.XbmcMetadata.Savers
internal static IEnumerable<string> GetMovieSavePaths(ItemInfo item)
{
+ var path = item.ContainingFolderPath;
if (item.VideoType == VideoType.Dvd && !item.IsPlaceHolder)
{
- var path = item.ContainingFolderPath;
-
yield return Path.Combine(path, "VIDEO_TS", "VIDEO_TS.nfo");
}
- if (!item.IsPlaceHolder && (item.VideoType == VideoType.Dvd || item.VideoType == VideoType.BluRay))
+ // only allow movie object to read movie.nfo, not owned videos (which will be itemtype video, not movie)
+ if (!item.IsInMixedFolder && item.ItemType == typeof(Movie))
{
- var path = item.ContainingFolderPath;
+ yield return Path.Combine(path, "movie.nfo");
+ }
+ if (!item.IsPlaceHolder && (item.VideoType == VideoType.Dvd || item.VideoType == VideoType.BluRay))
+ {
yield return Path.Combine(path, Path.GetFileName(path) + ".nfo");
}
else
{
- // only allow movie object to read movie.nfo, not owned videos (which will be itemtype video, not movie)
- if (!item.IsInMixedFolder && item.ItemType == typeof(Movie))
- {
- yield return Path.Combine(item.ContainingFolderPath, "movie.nfo");
- }
-
yield return Path.ChangeExtension(item.Path, ".nfo");
}
}
diff --git a/README.md b/README.md
index 36828f59a..e5e16c716 100644
--- a/README.md
+++ b/README.md
@@ -16,9 +16,6 @@
<a href="https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/?utm_source=widget">
<img alt="Translation Status" src="https://translate.jellyfin.org/widgets/jellyfin/-/jellyfin-core/svg-badge.svg"/>
</a>
-<a href="https://dev.azure.com/jellyfin-project/jellyfin/_build?definitionId=29">
-<img alt="Azure Builds" src="https://dev.azure.com/jellyfin-project/jellyfin/_apis/build/status/Jellyfin%20Server"/>
-</a>
<a href="https://hub.docker.com/r/jellyfin/jellyfin">
<img alt="Docker Pull Count" src="https://img.shields.io/docker/pulls/jellyfin/jellyfin.svg"/>
</a>
@@ -30,10 +27,7 @@
<img alt="Submit Feature Requests" src="https://img.shields.io/badge/fider-vote%20on%20features-success.svg"/>
</a>
<a href="https://matrix.to/#/#jellyfinorg:matrix.org">
-<img alt="Chat on Matrix" src="https://img.shields.io/matrix/jellyfin:matrix.org.svg?logo=matrix"/>
-</a>
-<a href="https://www.reddit.com/r/jellyfin">
-<img alt="Join our Subreddit" src="https://img.shields.io/badge/reddit-r%2Fjellyfin-%23FF5700.svg"/>
+<img alt="Chat on Matrix" src="https://img.shields.io/matrix/jellyfinorg:matrix.org.svg?logo=matrix"/>
</a>
<a href="https://github.com/jellyfin/jellyfin/releases.atom">
<img alt="Release RSS Feed" src="https://img.shields.io/badge/rss-releases-ffa500?logo=rss" />
diff --git a/src/Jellyfin.Extensions/StreamExtensions.cs b/src/Jellyfin.Extensions/StreamExtensions.cs
index 182996852..0cfac384e 100644
--- a/src/Jellyfin.Extensions/StreamExtensions.cs
+++ b/src/Jellyfin.Extensions/StreamExtensions.cs
@@ -1,7 +1,9 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Runtime.CompilerServices;
using System.Text;
+using System.Threading;
namespace Jellyfin.Extensions
{
@@ -48,11 +50,12 @@ namespace Jellyfin.Extensions
/// Reads all lines in the <see cref="TextReader" />.
/// </summary>
/// <param name="reader">The <see cref="TextReader" /> to read from.</param>
+ /// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
/// <returns>All lines in the stream.</returns>
- public static async IAsyncEnumerable<string> ReadAllLinesAsync(this TextReader reader)
+ public static async IAsyncEnumerable<string> ReadAllLinesAsync(this TextReader reader, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
string? line;
- while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) is not null)
+ while ((line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) is not null)
{
yield return line;
}
diff --git a/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandlerTests.cs
index 2e6ffb5f6..31d2b486b 100644
--- a/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandlerTests.cs
+++ b/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandlerTests.cs
@@ -1,14 +1,19 @@
+using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading.Tasks;
using AutoFixture;
using AutoFixture.AutoMoq;
+using Jellyfin.Api.Auth.DefaultAuthorizationPolicy;
using Jellyfin.Api.Auth.FirstTimeSetupPolicy;
using Jellyfin.Api.Constants;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Library;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
using Moq;
using Xunit;
@@ -18,7 +23,9 @@ namespace Jellyfin.Api.Tests.Auth.FirstTimeSetupPolicy
{
private readonly Mock<IConfigurationManager> _configurationManagerMock;
private readonly List<IAuthorizationRequirement> _requirements;
+ private readonly DefaultAuthorizationHandler _defaultAuthorizationHandler;
private readonly FirstTimeSetupHandler _firstTimeSetupHandler;
+ private readonly IAuthorizationService _authorizationService;
private readonly Mock<IUserManager> _userManagerMock;
private readonly Mock<IHttpContextAccessor> _httpContextAccessor;
@@ -31,6 +38,21 @@ namespace Jellyfin.Api.Tests.Auth.FirstTimeSetupPolicy
_httpContextAccessor = fixture.Freeze<Mock<IHttpContextAccessor>>();
_firstTimeSetupHandler = fixture.Create<FirstTimeSetupHandler>();
+ _defaultAuthorizationHandler = fixture.Create<DefaultAuthorizationHandler>();
+
+ var services = new ServiceCollection();
+ services.AddAuthorizationCore();
+ services.AddLogging();
+ services.AddOptions();
+ services.AddSingleton<IAuthorizationHandler>(_defaultAuthorizationHandler);
+ services.AddSingleton<IAuthorizationHandler>(_firstTimeSetupHandler);
+ services.AddAuthorization(options =>
+ {
+ options.AddPolicy("FirstTime", policy => policy.Requirements.Add(new FirstTimeSetupRequirement()));
+ options.AddPolicy("FirstTimeNoAdmin", policy => policy.Requirements.Add(new FirstTimeSetupRequirement(false, false)));
+ options.AddPolicy("FirstTimeSchedule", policy => policy.Requirements.Add(new FirstTimeSetupRequirement(true, false)));
+ });
+ _authorizationService = services.BuildServiceProvider().GetRequiredService<IAuthorizationService>();
}
[Theory]
@@ -45,10 +67,9 @@ namespace Jellyfin.Api.Tests.Auth.FirstTimeSetupPolicy
_httpContextAccessor,
userRole);
- var context = new AuthorizationHandlerContext(_requirements, claims, null);
+ var allowed = await _authorizationService.AuthorizeAsync(claims, "FirstTime");
- await _firstTimeSetupHandler.HandleAsync(context);
- Assert.True(context.HasSucceeded);
+ Assert.True(allowed.Succeeded);
}
[Theory]
@@ -63,17 +84,16 @@ namespace Jellyfin.Api.Tests.Auth.FirstTimeSetupPolicy
_httpContextAccessor,
userRole);
- var context = new AuthorizationHandlerContext(_requirements, claims, null);
+ var allowed = await _authorizationService.AuthorizeAsync(claims, "FirstTime");
- await _firstTimeSetupHandler.HandleAsync(context);
- Assert.Equal(shouldSucceed, context.HasSucceeded);
+ Assert.Equal(shouldSucceed, allowed.Succeeded);
}
[Theory]
[InlineData(UserRoles.Administrator, true)]
[InlineData(UserRoles.Guest, false)]
[InlineData(UserRoles.User, true)]
- public async Task ShouldRequireUserIfNotRequiresAdmin(string userRole, bool shouldSucceed)
+ public async Task ShouldRequireUserIfNotAdministrator(string userRole, bool shouldSucceed)
{
TestHelpers.SetupConfigurationManager(_configurationManagerMock, true);
var claims = TestHelpers.SetupUser(
@@ -81,24 +101,26 @@ namespace Jellyfin.Api.Tests.Auth.FirstTimeSetupPolicy
_httpContextAccessor,
userRole);
- var context = new AuthorizationHandlerContext(
- new List<IAuthorizationRequirement> { new FirstTimeSetupRequirement(false, false) },
- claims,
- null);
+ var allowed = await _authorizationService.AuthorizeAsync(claims, "FirstTimeNoAdmin");
- await _firstTimeSetupHandler.HandleAsync(context);
- Assert.Equal(shouldSucceed, context.HasSucceeded);
+ Assert.Equal(shouldSucceed, allowed.Succeeded);
}
[Fact]
- public async Task ShouldAllowAdminApiKeyIfStartupWizardComplete()
+ public async Task ShouldDisallowUserIfOutsideSchedule()
{
+ AccessSchedule[] accessSchedules = { new AccessSchedule(DynamicDayOfWeek.Everyday, 0, 0, Guid.Empty) };
+
TestHelpers.SetupConfigurationManager(_configurationManagerMock, true);
- var claims = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.Role, UserRoles.Administrator)]));
- var context = new AuthorizationHandlerContext(_requirements, claims, null);
+ var claims = TestHelpers.SetupUser(
+ _userManagerMock,
+ _httpContextAccessor,
+ UserRoles.User,
+ accessSchedules);
+
+ var allowed = await _authorizationService.AuthorizeAsync(claims, "FirstTimeSchedule");
- await _firstTimeSetupHandler.HandleAsync(context);
- Assert.True(context.HasSucceeded);
+ Assert.False(allowed.Succeeded);
}
}
}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeExternalSourcesTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeExternalSourcesTests.cs
index 263f74c90..84008cffd 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeExternalSourcesTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeExternalSourcesTests.cs
@@ -35,7 +35,7 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
Protocol = MediaProtocol.Http,
RequiredHttpHeaders = new Dictionary<string, string>()
{
- { "user_agent", userAgent },
+ { "User-Agent", userAgent },
}
},
ExtractChapters = false,
@@ -44,7 +44,7 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
var extraArg = encoder.GetExtraArguments(req);
- Assert.Contains(userAgent, extraArg, StringComparison.InvariantCulture);
+ Assert.Contains($"-user_agent \"{userAgent}\"", extraArg, StringComparison.InvariantCulture);
}
}
}
diff --git a/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs
index be5a401b1..5dd3eb8ab 100644
--- a/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs
+++ b/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs
@@ -209,7 +209,7 @@ namespace Jellyfin.Providers.Tests.Manager
[InlineData(ImageType.Backdrop, 2, false)]
[InlineData(ImageType.Primary, 1, true)]
[InlineData(ImageType.Backdrop, 2, true)]
- public async void RefreshImages_PopulatedItemPopulatedProviderDynamic_UpdatesImagesIfForced(ImageType imageType, int imageCount, bool forceRefresh)
+ public async Task RefreshImages_PopulatedItemPopulatedProviderDynamic_UpdatesImagesIfForced(ImageType imageType, int imageCount, bool forceRefresh)
{
var item = GetItemWithImages(imageType, imageCount, false);
@@ -261,7 +261,7 @@ namespace Jellyfin.Providers.Tests.Manager
[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)
+ public async Task 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>();
@@ -311,7 +311,7 @@ namespace Jellyfin.Providers.Tests.Manager
[InlineData(ImageType.Primary, 1, true)]
[InlineData(ImageType.Backdrop, 1, true)]
[InlineData(ImageType.Backdrop, 2, true)]
- public async void RefreshImages_PopulatedItemPopulatedProviderRemote_UpdatesImagesIfForced(ImageType imageType, int imageCount, bool forceRefresh)
+ public async Task RefreshImages_PopulatedItemPopulatedProviderRemote_UpdatesImagesIfForced(ImageType imageType, int imageCount, bool forceRefresh)
{
var item = GetItemWithImages(imageType, imageCount, false);
@@ -366,7 +366,7 @@ namespace Jellyfin.Providers.Tests.Manager
[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)
+ public async Task RefreshImages_NonStubItemPopulatedProviderRemote_DownloadsIfNecessary(ImageType imageType, int initialImageCount, bool fullRefresh)
{
var targetImageCount = 1;
@@ -429,7 +429,7 @@ namespace Jellyfin.Providers.Tests.Manager
[Theory]
[MemberData(nameof(GetImageTypesWithCount))]
- public async void RefreshImages_EmptyItemPopulatedProviderRemoteExtras_LimitsImages(ImageType imageType, int imageCount)
+ public async Task RefreshImages_EmptyItemPopulatedProviderRemoteExtras_LimitsImages(ImageType imageType, int imageCount)
{
var item = new Video();
@@ -473,7 +473,7 @@ namespace Jellyfin.Providers.Tests.Manager
[Theory]
[MemberData(nameof(GetImageTypesWithCount))]
- public async void RefreshImages_PopulatedItemEmptyProviderRemoteFullRefresh_DoesntClearImages(ImageType imageType, int imageCount)
+ public async Task RefreshImages_PopulatedItemEmptyProviderRemoteFullRefresh_DoesntClearImages(ImageType imageType, int imageCount)
{
var item = GetItemWithImages(imageType, imageCount, false);
@@ -501,7 +501,7 @@ namespace Jellyfin.Providers.Tests.Manager
[InlineData(9, false)]
[InlineData(10, true)]
[InlineData(null, true)]
- public async void RefreshImages_ProviderRemote_FiltersByWidth(int? remoteImageWidth, bool expectedToUpdate)
+ public async Task RefreshImages_ProviderRemote_FiltersByWidth(int? remoteImageWidth, bool expectedToUpdate)
{
var imageType = ImageType.Primary;
@@ -575,18 +575,22 @@ namespace Jellyfin.Providers.Tests.Manager
// 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 Video();
+ var item = new Mock<Video>
+ {
+ CallBase = true
+ };
+ item.Setup(m => m.IsSaveLocalMetadataEnabled()).Returns(false);
var path = validPaths ? _testDataImagePath.Format : "invalid path {0}";
for (int i = 0; i < count; i++)
{
- item.SetImagePath(type, i, new FileSystemMetadata
+ item.Object.SetImagePath(type, i, new FileSystemMetadata
{
FullName = string.Format(CultureInfo.InvariantCulture, path, i),
});
}
- return item;
+ return item.Object;
}
private static ILocalImageProvider GetImageProvider(ImageType type, int count, bool validPaths)
diff --git a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs
index ec4df9981..cedcaf9c0 100644
--- a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs
+++ b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Entities.Movies;
@@ -19,7 +20,7 @@ namespace Jellyfin.Providers.Tests.Manager
[InlineData(true, true)]
public void MergeBaseItemData_MergeMetadataSettings_MergesWhenSet(bool mergeMetadataSettings, bool defaultDate)
{
- var newLocked = new[] { MetadataField.Cast };
+ var newLocked = new[] { MetadataField.Genres, MetadataField.Cast };
var newString = "new";
var newDate = DateTime.Now;
@@ -77,7 +78,7 @@ namespace Jellyfin.Providers.Tests.Manager
[Theory]
[InlineData("Name", MetadataField.Name, false)]
- [InlineData("OriginalTitle", null, false)]
+ [InlineData("OriginalTitle", null)]
[InlineData("OfficialRating", MetadataField.OfficialRating)]
[InlineData("CustomRating")]
[InlineData("Tagline")]
diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs
index d5f6873a2..290cb817a 100644
--- a/tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs
+++ b/tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs
@@ -64,7 +64,7 @@ public class AudioResolverTests
[InlineData("My.Video.mp3", false, true)]
[InlineData("My.Video.srt", true, false)]
[InlineData("My.Video.mp3", true, true)]
- public async void GetExternalStreams_MixedFilenames_PicksAudio(string file, bool metadataDirectory, bool matches)
+ public async Task GetExternalStreams_MixedFilenames_PicksAudio(string file, bool metadataDirectory, bool matches)
{
BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/EmbeddedImageProviderTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/EmbeddedImageProviderTests.cs
index 85963e5de..c0b41ba43 100644
--- a/tests/Jellyfin.Providers.Tests/MediaInfo/EmbeddedImageProviderTests.cs
+++ b/tests/Jellyfin.Providers.Tests/MediaInfo/EmbeddedImageProviderTests.cs
@@ -37,7 +37,7 @@ namespace Jellyfin.Providers.Tests.MediaInfo
}
[Fact]
- public async void GetImage_NoStreams_ReturnsNoImage()
+ public async Task GetImage_NoStreams_ReturnsNoImage()
{
var input = new Movie();
@@ -55,7 +55,7 @@ namespace Jellyfin.Providers.Tests.MediaInfo
[InlineData("clearlogo.png", null, 1, ImageType.Logo, ImageFormat.Png)] // extract extension from name
[InlineData("backdrop", "image/bmp", 2, ImageType.Backdrop, ImageFormat.Bmp)] // extract extension from mimetype
[InlineData("poster", null, 3, ImageType.Primary, ImageFormat.Jpg)] // default extension to jpg
- public async void GetImage_Attachment_ReturnsCorrectSelection(string filename, string? mimetype, int targetIndex, ImageType type, ImageFormat? expectedFormat)
+ public async Task GetImage_Attachment_ReturnsCorrectSelection(string filename, string? mimetype, int targetIndex, ImageType type, ImageFormat? expectedFormat)
{
var attachments = new List<MediaAttachment>();
string pathPrefix = "path";
@@ -103,7 +103,7 @@ namespace Jellyfin.Providers.Tests.MediaInfo
[InlineData(null, "mjpeg", 1, ImageType.Primary, ImageFormat.Jpg)]
[InlineData(null, "png", 1, ImageType.Primary, ImageFormat.Png)]
[InlineData(null, "webp", 1, ImageType.Primary, ImageFormat.Webp)]
- public async void GetImage_Embedded_ReturnsCorrectSelection(string? label, string? codec, int targetIndex, ImageType type, ImageFormat? expectedFormat)
+ public async Task GetImage_Embedded_ReturnsCorrectSelection(string? label, string? codec, int targetIndex, ImageType type, ImageFormat? expectedFormat)
{
var streams = new List<MediaStream>();
for (int i = 1; i <= targetIndex; i++)
diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs
index 58b67ae55..db427308c 100644
--- a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs
+++ b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs
@@ -182,7 +182,7 @@ public class MediaInfoResolverTests
[Theory]
[InlineData("https://url.com/My.Video.mkv")]
[InlineData(VideoDirectoryPath)] // valid but no files found for this test
- public async void GetExternalStreams_BadPaths_ReturnsNoSubtitles(string path)
+ public async Task GetExternalStreams_BadPaths_ReturnsNoSubtitles(string path)
{
// need a media source manager capable of returning something other than file protocol
var mediaSourceManager = new Mock<IMediaSourceManager>();
@@ -285,7 +285,7 @@ public class MediaInfoResolverTests
[Theory]
[MemberData(nameof(GetExternalStreams_MergeMetadata_HandlesOverridesCorrectly_Data))]
- public async void GetExternalStreams_MergeMetadata_HandlesOverridesCorrectly(string file, MediaStream[] inputStreams, MediaStream[] expectedStreams)
+ public async Task GetExternalStreams_MergeMetadata_HandlesOverridesCorrectly(string file, MediaStream[] inputStreams, MediaStream[] expectedStreams)
{
BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
@@ -335,7 +335,7 @@ public class MediaInfoResolverTests
[InlineData(1, 2)]
[InlineData(2, 1)]
[InlineData(2, 2)]
- public async void GetExternalStreams_StreamIndex_HandlesFilesAndContainers(int fileCount, int streamCount)
+ public async Task GetExternalStreams_StreamIndex_HandlesFilesAndContainers(int fileCount, int streamCount)
{
BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs
index 8077bd791..e0d365927 100644
--- a/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs
+++ b/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs
@@ -64,7 +64,7 @@ public class SubtitleResolverTests
[InlineData("My.Video.mp3", false, false)]
[InlineData("My.Video.srt", true, true)]
[InlineData("My.Video.mp3", true, false)]
- public async void GetExternalStreams_MixedFilenames_PicksSubtitles(string file, bool metadataDirectory, bool matches)
+ public async Task GetExternalStreams_MixedFilenames_PicksSubtitles(string file, bool metadataDirectory, bool matches)
{
BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/VideoImageProviderTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/VideoImageProviderTests.cs
index 7ea6f7d9c..028f6feba 100644
--- a/tests/Jellyfin.Providers.Tests/MediaInfo/VideoImageProviderTests.cs
+++ b/tests/Jellyfin.Providers.Tests/MediaInfo/VideoImageProviderTests.cs
@@ -34,7 +34,7 @@ namespace Jellyfin.Providers.Tests.MediaInfo
[Theory]
[MemberData(nameof(GetImage_UnsupportedInput_ReturnsNoImage_TestData))]
- public async void GetImage_UnsupportedInput_ReturnsNoImage(Video input)
+ public async Task GetImage_UnsupportedInput_ReturnsNoImage(Video input)
{
var mediaSourceManager = GetMediaSourceManager(input, null, new List<MediaStream>());
var videoImageProvider = new VideoImageProvider(mediaSourceManager, Mock.Of<IMediaEncoder>(), new NullLogger<VideoImageProvider>());
@@ -47,7 +47,7 @@ namespace Jellyfin.Providers.Tests.MediaInfo
[Theory]
[InlineData(1, 1)] // default not first stream
[InlineData(5, 0)] // default out of valid range
- public async void GetImage_DefaultVideoStreams_ReturnsCorrectStreamImage(int defaultIndex, int targetIndex)
+ public async Task GetImage_DefaultVideoStreams_ReturnsCorrectStreamImage(int defaultIndex, int targetIndex)
{
var input = new Movie { DefaultVideoStreamIndex = defaultIndex };
@@ -80,7 +80,7 @@ namespace Jellyfin.Providers.Tests.MediaInfo
[Theory]
[InlineData(null, 10)] // default time
[InlineData(500, 50)] // calculated time
- public async void GetImage_TimeSpan_SelectsCorrectTime(int? runTimeSeconds, long expectedSeconds)
+ public async Task GetImage_TimeSpan_SelectsCorrectTime(int? runTimeSeconds, long expectedSeconds)
{
MediaStream targetStream = new() { Type = MediaStreamType.Video, Index = 0 };
var input = new Movie
diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs
new file mode 100644
index 000000000..bf3bfdad4
--- /dev/null
+++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs
@@ -0,0 +1,114 @@
+using System;
+using System.Linq;
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Jellyfin.Api.Models.LibraryStructureDto;
+using Jellyfin.Extensions.Json;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Entities;
+using Xunit;
+using Xunit.Priority;
+
+namespace Jellyfin.Server.Integration.Tests.Controllers;
+
+[TestCaseOrderer(PriorityOrderer.Name, PriorityOrderer.Assembly)]
+public sealed class LibraryStructureControllerTests : IClassFixture<JellyfinApplicationFactory>
+{
+ private readonly JellyfinApplicationFactory _factory;
+ private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
+ private static string? _accessToken;
+
+ public LibraryStructureControllerTests(JellyfinApplicationFactory factory)
+ {
+ _factory = factory;
+ }
+
+ [Fact]
+ [Priority(-1)]
+ public async Task Post_NewVirtualFolder_NotFound()
+ {
+ var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client));
+
+ var body = new AddVirtualFolderDto()
+ {
+ LibraryOptions = new LibraryOptions()
+ {
+ Enabled = false
+ }
+ };
+
+ using var response = await client.PostAsJsonAsync("Library/VirtualFolders?name=test&refreshLibrary=true", body, _jsonOptions);
+ Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
+ }
+
+ [Fact]
+ [Priority(0)]
+ public async Task UpdateLibraryOptions_Invalid_NotFound()
+ {
+ var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client));
+
+ var body = new UpdateLibraryOptionsDto()
+ {
+ Id = Guid.NewGuid(),
+ LibraryOptions = new LibraryOptions()
+ };
+
+ using var response = await client.PostAsJsonAsync("Library/VirtualFolders/LibraryOptions", body, _jsonOptions);
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ [Fact]
+ [Priority(0)]
+ public async Task UpdateLibraryOptions_Valid_Success()
+ {
+ var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client));
+
+ using var response = await client.GetAsync("Library/VirtualFolders");
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var library = await response.Content.ReadFromJsonAsAsyncEnumerable<VirtualFolderInfo>(_jsonOptions)
+ .FirstOrDefaultAsync(x => string.Equals(x?.Name, "test", StringComparison.Ordinal));
+ Assert.NotNull(library);
+
+ var options = library.LibraryOptions;
+ Assert.NotNull(options);
+ Assert.False(options.Enabled);
+ options.Enabled = true;
+
+ var body = new UpdateLibraryOptionsDto()
+ {
+ Id = Guid.Parse(library.ItemId),
+ LibraryOptions = options
+ };
+
+ using var response2 = await client.PostAsJsonAsync("Library/VirtualFolders/LibraryOptions", body, _jsonOptions);
+ Assert.Equal(HttpStatusCode.NoContent, response2.StatusCode);
+ }
+
+ [Fact]
+ [Priority(1)]
+ public async Task DeleteLibrary_Invalid_NotFound()
+ {
+ var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client));
+
+ using var response = await client.DeleteAsync("Library/VirtualFolders?name=doesntExist");
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ [Fact]
+ [Priority(1)]
+ public async Task DeleteLibrary_Valid_Success()
+ {
+ var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client));
+
+ using var response = await client.DeleteAsync("Library/VirtualFolders?name=test&refreshLibrary=true");
+ Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
+ }
+}
diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Location/MovieNfoLocationTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Location/MovieNfoLocationTests.cs
index 8019e0ab3..2f05c4ea2 100644
--- a/tests/Jellyfin.XbmcMetadata.Tests/Location/MovieNfoLocationTests.cs
+++ b/tests/Jellyfin.XbmcMetadata.Tests/Location/MovieNfoLocationTests.cs
@@ -47,6 +47,7 @@ namespace Jellyfin.XbmcMetadata.Tests.Location
var movie = new Movie() { Path = "/media/movies/Avengers Endgame", VideoType = VideoType.Dvd };
var path1 = "/media/movies/Avengers Endgame/Avengers Endgame.nfo";
var path2 = "/media/movies/Avengers Endgame/VIDEO_TS/VIDEO_TS.nfo";
+ var path3 = "/media/movies/Avengers Endgame/movie.nfo";
// uses ContainingFolderPath which uses Operating system specific paths
if (OperatingSystem.IsWindows())
@@ -54,12 +55,14 @@ namespace Jellyfin.XbmcMetadata.Tests.Location
movie.Path = movie.Path.Replace('/', '\\');
path1 = path1.Replace('/', '\\');
path2 = path2.Replace('/', '\\');
+ path3 = path3.Replace('/', '\\');
}
var paths = MovieNfoSaver.GetMovieSavePaths(new ItemInfo(movie)).ToArray();
- Assert.Equal(2, paths.Length);
+ Assert.Equal(3, paths.Length);
Assert.Contains(path1, paths);
Assert.Contains(path2, paths);
+ Assert.Contains(path3, paths);
}
}
}