diff options
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"); } } @@ -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); } } } |
