diff options
| author | Logan Douglas <42654828+JadedRain@users.noreply.github.com> | 2025-10-31 13:06:17 -0600 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-10-31 13:06:17 -0600 |
| commit | 490bf347cbdf8ec458996d09ba40651eb647b7b9 (patch) | |
| tree | 1bdad774667bf5e69f3fe7397a27f631a5ca617a | |
| parent | fd6e48603bcf143a1bbc3b1bda26a8e1664f9379 (diff) | |
| parent | 23929a3e709f4324d49271c02b0b047e1149e860 (diff) | |
Merge branch 'jellyfin:master' into master
78 files changed, 792 insertions, 533 deletions
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index b4d77bc4c6..df2b50e269 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "9.0.9", + "version": "9.0.10", "commands": [ "dotnet-ef" ] diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e1900d5833..ef81678ddd 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,4 +1,11 @@ -# Joshua must review all changes to deployment and build.sh -.ci/* @joshuaboniface -deployment/* @joshuaboniface -build.sh @joshuaboniface +# Joshua must review all changes to bump_version and any files it touches +bump_version @joshuaboniface +.github/ISSUE_TEMPLATE @joshuaboniface +MediaBrowser.Common/MediaBrowser.Common.csproj @joshuaboniface +Jellyfin.Data/Jellyfin.Data.csproj @joshuaboniface +MediaBrowser.Controller/MediaBrowser.Controller.csproj @joshuaboniface +MediaBrowser.Model/MediaBrowser.Model.csproj @joshuaboniface +Emby.Naming/Emby.Naming.csproj @joshuaboniface +src/Jellyfin.Extensions/Jellyfin.Extensions.csproj @joshuaboniface +# Core must approve all changes within the repo config +.github/ @jellyfin/core diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index 473302ddef..53daf0991f 100644 --- a/.github/workflows/ci-codeql-analysis.yml +++ b/.github/workflows/ci-codeql-analysis.yml @@ -27,11 +27,11 @@ jobs: dotnet-version: '9.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7 + uses: github/codeql-action/init@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7 + uses: github/codeql-action/autobuild@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7 + uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0 diff --git a/.github/workflows/ci-compat.yml b/.github/workflows/ci-compat.yml index a8104a917d..0651233386 100644 --- a/.github/workflows/ci-compat.yml +++ b/.github/workflows/ci-compat.yml @@ -26,7 +26,7 @@ jobs: dotnet build Jellyfin.Server -o ./out - name: Upload Head - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: abi-head retention-days: 14 @@ -65,7 +65,7 @@ jobs: dotnet build Jellyfin.Server -o ./out - name: Upload Head - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: abi-base retention-days: 14 @@ -85,13 +85,13 @@ jobs: steps: - name: Download abi-head - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: abi-head path: abi-head - name: Download abi-base - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: abi-base path: abi-base diff --git a/.github/workflows/ci-openapi.yml b/.github/workflows/ci-openapi.yml index 7cca2af274..d37602690d 100644 --- a/.github/workflows/ci-openapi.yml +++ b/.github/workflows/ci-openapi.yml @@ -27,7 +27,7 @@ jobs: - name: Generate openapi.json run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests" - name: Upload openapi.json - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: openapi-head retention-days: 14 @@ -61,7 +61,7 @@ jobs: - name: Generate openapi.json run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests" - name: Upload openapi.json - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: openapi-base retention-days: 14 @@ -80,12 +80,12 @@ jobs: - openapi-base steps: - name: Download openapi-head - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: openapi-head path: openapi-head - name: Download openapi-base - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: openapi-base path: openapi-base @@ -158,7 +158,7 @@ jobs: run: |- echo "JELLYFIN_VERSION=$(date +'%Y%m%d%H%M%S')" >> $GITHUB_ENV - name: Download openapi-head - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: openapi-head path: openapi-head @@ -220,7 +220,7 @@ jobs: run: |- echo "JELLYFIN_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV - name: Download openapi-head - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: openapi-head path: openapi-head diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 846835491a..b9fdd456f1 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -35,7 +35,7 @@ jobs: --verbosity minimal - name: Merge code coverage results - uses: danielpalme/ReportGenerator-GitHub-Action@9870ed167742d546b99962ff815fcc1098355ed8 # v5.4.17 + uses: danielpalme/ReportGenerator-GitHub-Action@f3c6b3f8a29686284ef7a7cf6dccb79a01d98444 # v5.4.18 with: reports: "**/coverage.cobertura.xml" targetdir: "merged/" diff --git a/Directory.Packages.props b/Directory.Packages.props index 857b1a7ef0..12aab38288 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -26,32 +26,32 @@ <PackageVersion Include="libse" Version="4.0.12" /> <PackageVersion Include="LrcParser" Version="2025.623.0" /> <PackageVersion Include="MetaBrainz.MusicBrainz" Version="6.1.0" /> - <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.9" /> - <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.9" /> + <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.10" /> + <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.10" /> <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="4.14.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" /> - <PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.9" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.9" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.9" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.9" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.9" /> - <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.9" /> - <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.9" /> - <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.9" /> - <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.9" /> - <PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.9" /> - <PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.9" /> - <PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.9" /> - <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.9" /> - <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.9" /> - <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.9" /> - <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.9" /> - <PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.9" /> - <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.9" /> - <PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.9" /> - <PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.9" /> + <PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.10" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.10" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.10" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.10" /> + <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.10" /> + <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.10" /> + <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.10" /> + <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.10" /> + <PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.10" /> + <PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.10" /> + <PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.10" /> + <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.10" /> + <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.10" /> + <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.10" /> + <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.10" /> + <PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.10" /> + <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" /> + <PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.10" /> + <PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.10" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.0" /> <PackageVersion Include="MimeTypes" Version="2.5.2" /> <PackageVersion Include="Morestachio" Version="5.0.1.631" /> @@ -84,11 +84,11 @@ <PackageVersion Include="Swashbuckle.AspNetCore" Version="6.2.3" /> <PackageVersion Include="System.Globalization" Version="4.3.0" /> <PackageVersion Include="System.Linq.Async" Version="6.0.3" /> - <PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.9" /> - <PackageVersion Include="System.Text.Json" Version="9.0.9" /> - <PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.9" /> + <PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.10" /> + <PackageVersion Include="System.Text.Json" Version="9.0.10" /> + <PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.10" /> <PackageVersion Include="TagLibSharp" Version="2.3.0" /> - <PackageVersion Include="z440.atl.core" Version="7.5.0" /> + <PackageVersion Include="z440.atl.core" Version="7.6.0" /> <PackageVersion Include="TMDbLib" Version="2.3.0" /> <PackageVersion Include="UTF.Unknown" Version="2.6.0" /> <PackageVersion Include="Xunit.Priority" Version="1.1.6" /> diff --git a/Emby.Naming/Emby.Naming.csproj b/Emby.Naming/Emby.Naming.csproj index 20b32f3a62..b84c961165 100644 --- a/Emby.Naming/Emby.Naming.csproj +++ b/Emby.Naming/Emby.Naming.csproj @@ -36,7 +36,7 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Naming</PackageId> - <VersionPrefix>10.11.0</VersionPrefix> + <VersionPrefix>10.12.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> diff --git a/Emby.Naming/TV/SeasonPathParser.cs b/Emby.Naming/TV/SeasonPathParser.cs index 98ee1e4b8f..90aae2d485 100644 --- a/Emby.Naming/TV/SeasonPathParser.cs +++ b/Emby.Naming/TV/SeasonPathParser.cs @@ -10,10 +10,10 @@ namespace Emby.Naming.TV /// </summary> public static partial class SeasonPathParser { - [GeneratedRegex(@"^\s*((?<seasonnumber>(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<rightpart>.*)$")] + [GeneratedRegex(@"^\s*((?<seasonnumber>(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<rightpart>.*)$", RegexOptions.IgnoreCase)] private static partial Regex ProcessPre(); - [GeneratedRegex(@"^\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<seasonnumber>(?>\d+)(?!\s*[Ee]\d+))(?<rightpart>.*)$")] + [GeneratedRegex(@"^\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<seasonnumber>(?>\d+)(?!\s*[Ee]\d+))(?<rightpart>.*)$", RegexOptions.IgnoreCase)] private static partial Regex ProcessPost(); /// <summary> @@ -86,7 +86,7 @@ namespace Emby.Naming.TV } } - if (filename.StartsWith('s')) + if (filename.Length > 0 && (filename[0] == 'S' || filename[0] == 's')) { var testFilename = filename.AsSpan()[1..]; diff --git a/Emby.Server.Implementations/Chapters/ChapterManager.cs b/Emby.Server.Implementations/Chapters/ChapterManager.cs index b4daa2a143..d09ed30ae3 100644 --- a/Emby.Server.Implementations/Chapters/ChapterManager.cs +++ b/Emby.Server.Implementations/Chapters/ChapterManager.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.IO; using System.Linq; using System.Threading; @@ -224,7 +223,7 @@ public class ChapterManager : IChapterManager if (saveChapters && changesMade) { - _chapterRepository.SaveChapters(video.Id, chapters); + SaveChapters(video, chapters); } DeleteDeadImages(currentImages, chapters); @@ -235,7 +234,9 @@ public class ChapterManager : IChapterManager /// <inheritdoc /> public void SaveChapters(Video video, IReadOnlyList<ChapterInfo> chapters) { - _chapterRepository.SaveChapters(video.Id, chapters); + // Remove any chapters that are outside of the runtime of the video + var validChapters = chapters.Where(c => c.StartPositionTicks < video.RunTimeTicks).ToList(); + _chapterRepository.SaveChapters(video.Id, validChapters); } /// <inheritdoc /> @@ -251,23 +252,9 @@ public class ChapterManager : IChapterManager } /// <inheritdoc /> - public void DeleteChapterImages(Video video) + public async Task DeleteChapterDataAsync(Guid itemId, CancellationToken cancellationToken) { - var path = _pathManager.GetChapterImageFolderPath(video); - try - { - if (Directory.Exists(path)) - { - _logger.LogInformation("Removing chapter images for {Name} [{Id}]", video.Name, video.Id); - Directory.Delete(path, true); - } - } - catch (Exception ex) - { - _logger.LogWarning("Failed to remove chapter image folder for {Item}: {Exception}", video.Id, ex); - } - - _chapterRepository.DeleteChapters(video.Id); + await _chapterRepository.DeleteChaptersAsync(itemId, cancellationToken).ConfigureAwait(false); } private IReadOnlyList<string> GetSavedChapterImages(Video video, IDirectoryService directoryService) diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs index c9630b8945..97e89ca3d9 100644 --- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs +++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs @@ -152,6 +152,10 @@ namespace Emby.Server.Implementations.IO /// <inheritdoc /> public void MoveDirectory(string source, string destination) { + // Make sure parent directory of target exists + var parent = Directory.GetParent(destination); + parent?.Create(); + try { Directory.Move(source, destination); @@ -248,47 +252,40 @@ namespace Emby.Server.Implementations.IO { result.IsDirectory = info is DirectoryInfo || (info.Attributes & FileAttributes.Directory) == FileAttributes.Directory; - // if (!result.IsDirectory) - // { - // result.IsHidden = (info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden; - // } - if (info is FileInfo fileInfo) { - result.Length = fileInfo.Length; - - // Issue #2354 get the size of files behind symbolic links. Also Enum.HasFlag is bad as it boxes! - if ((fileInfo.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint) + result.CreationTimeUtc = GetCreationTimeUtc(info); + result.LastWriteTimeUtc = GetLastWriteTimeUtc(info); + if (fileInfo.LinkTarget is not null) { try { - using (var fileHandle = File.OpenHandle(fileInfo.FullName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + var targetFileInfo = (FileInfo?)fileInfo.ResolveLinkTarget(returnFinalTarget: true); + if (targetFileInfo is not null) { - result.Length = RandomAccess.GetLength(fileHandle); + result.Exists = targetFileInfo.Exists; + if (result.Exists) + { + result.Length = targetFileInfo.Length; + result.CreationTimeUtc = GetCreationTimeUtc(targetFileInfo); + result.LastWriteTimeUtc = GetLastWriteTimeUtc(targetFileInfo); + } + } + else + { + result.Exists = false; } - } - catch (FileNotFoundException ex) - { - // Dangling symlinks cannot be detected before opening the file unfortunately... - _logger.LogError(ex, "Reading the file size of the symlink at {Path} failed. Marking the file as not existing.", fileInfo.FullName); - result.Exists = false; } catch (UnauthorizedAccessException ex) { _logger.LogError(ex, "Reading the file at {Path} failed due to a permissions exception.", fileInfo.FullName); } - catch (IOException ex) - { - // IOException generally means the file is not accessible due to filesystem issues - // Catch this exception and mark the file as not exist to ignore it - _logger.LogError(ex, "Reading the file at {Path} failed due to an IO Exception. Marking the file as not existing", fileInfo.FullName); - result.Exists = false; - } + } + else + { + result.Length = fileInfo.Length; } } - - result.CreationTimeUtc = GetCreationTimeUtc(info); - result.LastWriteTimeUtc = GetLastWriteTimeUtc(info); } else { diff --git a/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs b/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs index bafe3ad436..959acd4751 100644 --- a/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs +++ b/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs @@ -51,8 +51,7 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule } // Fast path in case the ignore files isn't a symlink and is empty - if ((dirIgnoreFile.Attributes & FileAttributes.ReparsePoint) == 0 - && dirIgnoreFile.Length == 0) + if (dirIgnoreFile.LinkTarget is null && dirIgnoreFile.Length == 0) { return true; } @@ -93,6 +92,12 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule private static string GetFileContent(FileInfo dirIgnoreFile) { + dirIgnoreFile = (FileInfo?)dirIgnoreFile.ResolveLinkTarget(returnFinalTarget: true) ?? dirIgnoreFile; + if (!dirIgnoreFile.Exists) + { + return string.Empty; + } + using (var reader = dirIgnoreFile.OpenText()) { return reader.ReadToEnd(); diff --git a/Emby.Server.Implementations/Library/ExternalDataManager.cs b/Emby.Server.Implementations/Library/ExternalDataManager.cs index d3cfa1d256..4ad0f999bf 100644 --- a/Emby.Server.Implementations/Library/ExternalDataManager.cs +++ b/Emby.Server.Implementations/Library/ExternalDataManager.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.MediaSegments; @@ -20,6 +21,7 @@ public class ExternalDataManager : IExternalDataManager private readonly IMediaSegmentManager _mediaSegmentManager; private readonly IPathManager _pathManager; private readonly ITrickplayManager _trickplayManager; + private readonly IChapterManager _chapterManager; private readonly ILogger<ExternalDataManager> _logger; /// <summary> @@ -29,18 +31,21 @@ public class ExternalDataManager : IExternalDataManager /// <param name="mediaSegmentManager">The media segment manager.</param> /// <param name="pathManager">The path manager.</param> /// <param name="trickplayManager">The trickplay manager.</param> + /// <param name="chapterManager">The chapter manager.</param> /// <param name="logger">The logger.</param> public ExternalDataManager( IKeyframeManager keyframeManager, IMediaSegmentManager mediaSegmentManager, IPathManager pathManager, ITrickplayManager trickplayManager, + IChapterManager chapterManager, ILogger<ExternalDataManager> logger) { _keyframeManager = keyframeManager; _mediaSegmentManager = mediaSegmentManager; _pathManager = pathManager; _trickplayManager = trickplayManager; + _chapterManager = chapterManager; _logger = logger; } @@ -67,5 +72,6 @@ public class ExternalDataManager : IExternalDataManager await _keyframeManager.DeleteKeyframeDataAsync(itemId, cancellationToken).ConfigureAwait(false); await _mediaSegmentManager.DeleteSegmentsAsync(itemId, cancellationToken).ConfigureAwait(false); await _trickplayManager.DeleteTrickplayDataAsync(itemId, cancellationToken).ConfigureAwait(false); + await _chapterManager.DeleteChapterDataAsync(itemId, cancellationToken).ConfigureAwait(false); } } diff --git a/Emby.Server.Implementations/Library/MusicManager.cs b/Emby.Server.Implementations/Library/MusicManager.cs index e0c8ae371b..e19ad3ef6e 100644 --- a/Emby.Server.Implementations/Library/MusicManager.cs +++ b/Emby.Server.Implementations/Library/MusicManager.cs @@ -28,7 +28,9 @@ namespace Emby.Server.Implementations.Library public IReadOnlyList<BaseItem> GetInstantMixFromSong(Audio item, User? user, DtoOptions dtoOptions) { - return GetInstantMixFromGenres(item.Genres, user, dtoOptions); + var instantMixItems = GetInstantMixFromGenres(item.Genres, user, dtoOptions); + + return [item, .. instantMixItems.Where(i => !i.Id.Equals(item.Id))]; } /// <inheritdoc /> diff --git a/Emby.Server.Implementations/Localization/Core/es-MX.json b/Emby.Server.Implementations/Localization/Core/es-MX.json index 52a26c1af2..2830c657b6 100644 --- a/Emby.Server.Implementations/Localization/Core/es-MX.json +++ b/Emby.Server.Implementations/Localization/Core/es-MX.json @@ -30,7 +30,7 @@ "ItemAddedWithName": "{0} fue agregado a la biblioteca", "ItemRemovedWithName": "{0} fue removido de la biblioteca", "LabelIpAddressValue": "Dirección IP: {0}", - "LabelRunningTimeValue": "Tiempo de reproducción: {0}", + "LabelRunningTimeValue": "Tiempo corriendo: {0}", "Latest": "Recientes", "MessageApplicationUpdated": "El servidor Jellyfin ha sido actualizado", "MessageApplicationUpdatedTo": "El servidor Jellyfin ha sido actualizado a {0}", diff --git a/Emby.Server.Implementations/Localization/Core/et.json b/Emby.Server.Implementations/Localization/Core/et.json index 30d38dde37..a3f9dc2f8f 100644 --- a/Emby.Server.Implementations/Localization/Core/et.json +++ b/Emby.Server.Implementations/Localization/Core/et.json @@ -1,14 +1,14 @@ { "TaskCleanActivityLogDescription": "Kustutab määratud ajast vanemad tegevuslogi kirjed.", - "UserDownloadingItemWithValues": "{0} laeb alla {1}", + "UserDownloadingItemWithValues": "{0} laadib alla {1}", "HeaderRecordingGroups": "Salvestusrühmad", "TaskOptimizeDatabaseDescription": "Tihendab ja puhastab andmebaasi. Selle toimingu tegemine pärast meediakogu andmebaasiga seotud muudatuste skannimist võib jõudlust parandada.", "TaskOptimizeDatabase": "Optimeeri andmebaasi", "TaskDownloadMissingSubtitlesDescription": "Otsib veebist puuduvaid subtiitreid vastavalt määratud metaandmete seadetele.", - "TaskDownloadMissingSubtitles": "Laadi alla puuduvad subtiitrid", + "TaskDownloadMissingSubtitles": "Hangi puuduvad subtiitrid", "TaskRefreshChannelsDescription": "Värskendab veebikanalite teavet.", "TaskRefreshChannels": "Värskenda kanaleid", - "TaskCleanTranscodeDescription": "Kustutab üle ühe päeva vanused transkodeerimisfailid.", + "TaskCleanTranscodeDescription": "Kustutab üle ühe päeva vanused transkoodimisfailid.", "TaskCleanTranscode": "Puhasta transkoodimise kataloog", "TaskUpdatePluginsDescription": "Laadib alla ja paigaldab nende pluginate uuendused, mis on seadistatud automaatselt uuenduma.", "TaskUpdatePlugins": "Uuenda pluginaid", @@ -41,10 +41,10 @@ "StartupEmbyServerIsLoading": "Jellyfin server laadib. Proovi varsti uuesti.", "User": "Kasutaja", "Undefined": "Määratlemata", - "TvShows": "Seriaalid", + "TvShows": "Sarjad", "System": "Süsteem", "Sync": "Sünkrooni", - "Songs": "Laulud", + "Songs": "Lood", "Shows": "Sarjad", "ServerNameNeedsToBeRestarted": "{0} tuleb taaskäivitada", "ScheduledTaskFailedWithName": "{0} nurjus", @@ -92,7 +92,7 @@ "HeaderNextUp": "Järgmisena", "HeaderLiveTV": "Otse TV", "HeaderFavoriteSongs": "Lemmiklood", - "HeaderFavoriteShows": "Lemmikseriaalid", + "HeaderFavoriteShows": "Lemmiksarjad", "HeaderFavoriteEpisodes": "Lemmikepisoodid", "HeaderFavoriteArtists": "Lemmikesitajad", "HeaderFavoriteAlbums": "Lemmikalbumid", @@ -122,20 +122,20 @@ "UserOnlineFromDevice": "{0} on ühendatud seadmest {1}", "External": "Väline", "HearingImpaired": "Kuulmispuudega", - "TaskKeyframeExtractorDescription": "Eraldab videofailidest võtmekaadreid, et luua täpsemaid HLS-i esitusloendeid. See ülesanne võib kesta pikka aega.", - "TaskKeyframeExtractor": "Võtmekaadrite eraldamine", + "TaskKeyframeExtractorDescription": "Eraldab videofailidest võtmekaadrid, et luua täpsemaid HLS-i esitusloendeid. See võib kesta pikka aega.", + "TaskKeyframeExtractor": "Eralda võtmekaadrid", "TaskRefreshTrickplayImages": "Loo trickplay pildid", "TaskRefreshTrickplayImagesDescription": "Loob trickplay eelvaated videotele lubatud meediakogudes.", - "TaskAudioNormalization": "Normaliseeri heli", + "TaskAudioNormalization": "Normaliseeri helitugevus", "TaskAudioNormalizationDescription": "Otsib failidest helitugevuse normaliseerimise teavet.", - "TaskCleanCollectionsAndPlaylistsDescription": "Eemaldab kogumikest ja esitusloenditest asjad, mida enam ei eksisteeri.", + "TaskCleanCollectionsAndPlaylistsDescription": "Eemaldab kogumikest ja esitusloenditest üksused, mida enam ei eksisteeri.", "TaskCleanCollectionsAndPlaylists": "Puhasta kogumikud ja esitusloendid", - "TaskDownloadMissingLyrics": "Lae alla puuduolev lüürika", - "TaskDownloadMissingLyricsDescription": "Lae lauludele alla lüürika", + "TaskDownloadMissingLyrics": "Hangi puuduvad laulusõnad", + "TaskDownloadMissingLyricsDescription": "Laulusõnade allalaadimine", "TaskMoveTrickplayImagesDescription": "Liigutab trickplay pildid meediakogu sätete kohaselt.", - "TaskExtractMediaSegments": "Meediasegmentide skaneerimine", + "TaskExtractMediaSegments": "Skaneeri meediasegmente", "TaskExtractMediaSegmentsDescription": "Eraldab või võtab meediasegmendid MediaSegment'i lubavatest pluginatest.", "TaskMoveTrickplayImages": "Muuda trickplay piltide asukoht", - "CleanupUserDataTask": "Kasutajaandmete puhastamise ülesanne", + "CleanupUserDataTask": "Puhasta kasutajaandmed", "CleanupUserDataTaskDescription": "Puhastab kõik kasutajaandmed (vaatamise olek, lemmikute olek jne) meediast, mis pole enam vähemalt 90 päeva saadaval olnud." } diff --git a/Emby.Server.Implementations/Localization/Core/gsw.json b/Emby.Server.Implementations/Localization/Core/gsw.json index f847d83d14..e1ee8cf7c4 100644 --- a/Emby.Server.Implementations/Localization/Core/gsw.json +++ b/Emby.Server.Implementations/Localization/Core/gsw.json @@ -11,7 +11,7 @@ "Collections": "Sammlungen", "DeviceOfflineWithName": "{0} wurde getrennt", "DeviceOnlineWithName": "{0} ist verbunden", - "FailedLoginAttemptWithUserName": "Fehlgeschlagener Anmeldeversuch von {0}", + "FailedLoginAttemptWithUserName": "Fählgschlagene Ameldeversuech vo {0}", "Favorites": "Favorite", "Folders": "Ordner", "Genres": "Genre", diff --git a/Emby.Server.Implementations/Localization/Core/hi.json b/Emby.Server.Implementations/Localization/Core/hi.json index 813b18ad4b..80db975ccb 100644 --- a/Emby.Server.Implementations/Localization/Core/hi.json +++ b/Emby.Server.Implementations/Localization/Core/hi.json @@ -129,5 +129,12 @@ "TaskAudioNormalization": "श्रव्य सामान्यीकरण", "TaskAudioNormalizationDescription": "श्रव्य सामान्यीकरण के लिए फाइलें अन्वेषण करें", "TaskDownloadMissingLyrics": "लापता गानों के बोल डाउनलोड करेँ", - "TaskDownloadMissingLyricsDescription": "गानों के बोल डाउनलोड करता है" + "TaskDownloadMissingLyricsDescription": "गानों के बोल डाउनलोड करता है", + "TaskExtractMediaSegments": "मीडिया सेगमेंट स्कैन", + "TaskExtractMediaSegmentsDescription": "मीडियासेगमेंट सक्षम प्लगइन्स से मीडिया सेगमेंट निकालता है या प्राप्त करता है।", + "TaskMoveTrickplayImages": "ट्रिकप्ले छवि स्थान माइग्रेट करें", + "TaskMoveTrickplayImagesDescription": "लाइब्रेरी सेटिंग्स के अनुसार मौजूदा ट्रिकप्ले फ़ाइलों को स्थानांतरित करता है।", + "TaskCleanCollectionsAndPlaylistsDescription": "संग्रहों और प्लेलिस्टों से उन आइटमों को हटाता है जो अब मौजूद नहीं हैं।", + "TaskCleanCollectionsAndPlaylists": "संग्रह और प्लेलिस्ट साफ़ करें", + "CleanupUserDataTask": "यूज़र डेटा की सफाई करता है।" } diff --git a/Emby.Server.Implementations/Localization/Core/ko.json b/Emby.Server.Implementations/Localization/Core/ko.json index efc9f61ddf..3d1b1ed271 100644 --- a/Emby.Server.Implementations/Localization/Core/ko.json +++ b/Emby.Server.Implementations/Localization/Core/ko.json @@ -136,5 +136,7 @@ "TaskMoveTrickplayImages": "트릭플레이 이미지 위치 마이그레이션", "TaskMoveTrickplayImagesDescription": "추출된 트릭플레이 이미지를 라이브러리 설정에 따라 이동합니다.", "TaskDownloadMissingLyrics": "누락된 가사 다운로드", - "TaskDownloadMissingLyricsDescription": "가사 다운로드" + "TaskDownloadMissingLyricsDescription": "가사 다운로드", + "CleanupUserDataTask": "사용자 데이터 정리 작업", + "CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다." } diff --git a/Emby.Server.Implementations/Localization/Core/mn.json b/Emby.Server.Implementations/Localization/Core/mn.json index 7b44f94873..a684ff2041 100644 --- a/Emby.Server.Implementations/Localization/Core/mn.json +++ b/Emby.Server.Implementations/Localization/Core/mn.json @@ -1,16 +1,16 @@ { - "Books": "Номууд", + "Books": "Номнууд", "HeaderNextUp": "Дараа нь", "HeaderContinueWatching": "Үргэлжлүүлэн үзэх", "Songs": "Дуунууд", - "Playlists": "Тоглуулах жагсаалт", - "Movies": "Кино", + "Playlists": "Тоглуулах жагсаалтууд", + "Movies": "Кинонууд", "Latest": "Сүүлийн үеийн", "Genres": "Төрлүүд", "Favorites": "Дуртай", - "Collections": "Багц", + "Collections": "Цуглуулгууд", "Artists": "Уран бүтээлчид", - "Albums": "Цомгууд", + "Albums": "Дуут цомгууд", "TaskExtractMediaSegments": "Медиа сегмент шалга", "TaskExtractMediaSegmentsDescription": "MediaSegment идэвхжүүлсэн залгаасуудаас медиа сегментүүдийг задлах эсвэл олж авах.", "TaskMoveTrickplayImages": "Трикплэй зургуудын байршлыг шилжүүлэх", @@ -63,15 +63,15 @@ "CameraImageUploadedFrom": "{0}-с шинэ зураг байршуулагдлаа", "Channels": "Сувгууд", "ChapterNameValue": "{0}-р бүлэг", - "Default": "Өгөгдмөл", + "Default": "Анхдагч", "DeviceOfflineWithName": "{0}-н холболт саллаа", "DeviceOnlineWithName": "{0} холбогдлоо", "FailedLoginAttemptWithUserName": "{0}-н нэвтрэх оролдлого амжилтгүй", - "Folders": "Хавтаснууд", + "Folders": "Хавтасууд", "Forced": "Хүчээр", "HeaderAlbumArtists": "Цомгийн уран бүтээлчид", "HeaderFavoriteAlbums": "Дуртай цомгууд", - "HeaderLiveTV": "Шууд", + "HeaderLiveTV": "Шууд ТВ", "HeaderRecordingGroups": "Бичлэгийн бүлгүүд", "HearingImpaired": "Сонсголын бэрхшээлтэй", "HomeVideos": "Үндсэн дүрсүүд", @@ -84,8 +84,8 @@ "MessageApplicationUpdatedTo": "Jellyfin Server {0} болж шинэчлэгдлээ", "MessageServerConfigurationUpdated": "Server-н тохиргоо шинэчлэгдлээ", "MixedContent": "Холимог агуулга", - "Music": "Дуу", - "MusicVideos": "Дууны клип", + "Music": "Хөгжим", + "MusicVideos": "Дууны клипүүд", "NameInstallFailed": "{0} суулгахад алдаа гарлаа", "NameSeasonNumber": "{0}-р улирал", "NameSeasonUnknown": "Улирал олдсонгүй", @@ -101,15 +101,15 @@ "NotificationOptionUserLockedOut": "Хэрэглэгчийг түгжив", "NotificationOptionVideoPlayback": "Бичлэгийг тоглуулж эхлэв", "Photos": "Зургууд", - "Plugin": "Plugin", + "Plugin": "Плагин", "PluginInstalledWithName": "{0}-г суулгалаа", "PluginUninstalledWithName": "{0}-г устгалаа", "PluginUpdatedWithName": "{0}-г шинэчиллээ", "ProviderValue": "Нийлүүлэгч: {0}", "ScheduledTaskStartedWithName": "{0}-г эхлүүлэв", "ServerNameNeedsToBeRestarted": "{0}-г дахин асаана уу", - "Shows": "Нэвтрүүлгүүд", - "Sync": "Дахин", + "Shows": "Шоу", + "Sync": "Синхрончлох", "System": "Систем", "TvShows": "ТВ нэвтрүүлгүүд", "Undefined": "Танисангүй", @@ -122,7 +122,7 @@ "UserPolicyUpdatedWithName": "Хэрэглэгчийн журмыг {0}-д зориулан шинэчиллээ", "UserStartedPlayingItemWithValues": "{0}-г {2} дээр {1}-г тоглуулж байна", "UserStoppedPlayingItemWithValues": "{0}-г {2} дээр {1}-г тоглуулж дуусгалаа", - "ValueSpecialEpisodeName": "Тусгай - {0}", + "ValueSpecialEpisodeName": "Онцгой - {0}", "VersionNumber": "Хувилбар {0}", "TasksMaintenanceCategory": "Засвар", "TasksLibraryCategory": "Сан", diff --git a/Emby.Server.Implementations/Localization/Core/mr.json b/Emby.Server.Implementations/Localization/Core/mr.json index 9cfeb407b6..727bbee168 100644 --- a/Emby.Server.Implementations/Localization/Core/mr.json +++ b/Emby.Server.Implementations/Localization/Core/mr.json @@ -132,5 +132,10 @@ "TaskDownloadMissingLyrics": "उपलब्ध नसलेली गीतपट्टी (Lyrics) डाउनलोड करा", "TaskAudioNormalization": "ऑडिओ सामान्यीकरण", "TaskAudioNormalizationDescription": "ऑडिओ सामान्यीकरणाचा डाटा स्कॅन करतो.", - "TaskDownloadMissingLyricsDescription": "गाण्यांची गीतपट्टी (Lyrics) डाउनलोड करतो" + "TaskDownloadMissingLyricsDescription": "गाण्यांची गीतपट्टी (Lyrics) डाउनलोड करतो", + "TaskExtractMediaSegmentsDescription": "सक्रिय असलेल्या प्लगिनमधून मीडिया विभाग प्राप्त करते.", + "TaskMoveTrickplayImagesDescription": "लायब्ररीच्या सेटिंग्जप्रमाणे आधीपासून अस्तित्वात असलेल्या ट्रिकप्ले फाइल्सचे स्थान बदलते.", + "TaskCleanCollectionsAndPlaylistsDescription": "जे संग्रह आणि प्लेलिस्ट आता अस्तित्वात नाहीत, त्यांमधील घटक हटवते.", + "CleanupUserDataTask": "वापरकर्ता डेटाची स्वच्छता प्रक्रिया", + "CleanupUserDataTaskDescription": "९० दिवसांहून अधिक काळ अनुपस्थित असलेल्या माध्यमांवरील सर्व वापरकर्ता माहिती (जसे पाहण्याची स्थिती, आवडी इ.) हटवते." } diff --git a/Emby.Server.Implementations/Localization/Core/pa.json b/Emby.Server.Implementations/Localization/Core/pa.json index 6062d97003..ced9204b46 100644 --- a/Emby.Server.Implementations/Localization/Core/pa.json +++ b/Emby.Server.Implementations/Localization/Core/pa.json @@ -134,6 +134,8 @@ "TaskCleanCollectionsAndPlaylistsDescription": "ਕਲੈਕਸ਼ਨਾਂ ਅਤੇ ਪਲੇਲਿਸਟਾਂ ਵਿੱਚੋਂ ਉਹ ਆਈਟਮ ਹਟਾਉਂਦਾ ਹੈ ਜੋ ਹੁਣ ਮੌਜੂਦ ਨਹੀਂ ਹਨ।", "TaskCleanCollectionsAndPlaylists": "ਕਲੈਕਸ਼ਨਾਂ ਅਤੇ ਪਲੇਲਿਸਟਾਂ ਨੂੰ ਸਾਫ ਕਰੋ", "TaskAudioNormalization": "ਆਵਾਜ਼ ਸਧਾਰਣੀਕਰਨ", - "TaskRefreshTrickplayImagesDescription": "ਚਲ ਰਹੀ ਲਾਇਬ੍ਰੇਰੀਆਂ ਵਿੱਚ ਵੀਡੀਓਜ਼ ਲਈ ਟ੍ਰਿਕਪਲੇ ਪ੍ਰੀਵਿਊ ਬਣਾਉਂਦਾ ਹੈ।", - "TaskKeyframeExtractorDescription": "ਕੀ-ਫ੍ਰੇਮਜ਼ ਨੂੰ ਵੀਡੀਓ ਫਾਈਲਾਂ ਵਿੱਚੋਂ ਨਿਕਾਲਦਾ ਹੈ ਤਾਂ ਜੋ ਹੋਰ ਜ਼ਿਆਦਾ ਸਟਿਕ ਹੋਣ ਵਾਲੀਆਂ HLS ਪਲੇਲਿਸਟਾਂ ਬਣਾਈਆਂ ਜਾ ਸਕਣ। ਇਹ ਕੰਮ ਲੰਬੇ ਸਮੇਂ ਤੱਕ ਚੱਲ ਸਕਦਾ ਹੈ।" + "TaskRefreshTrickplayImagesDescription": "ਵੀਡੀਓ ਲਈ ਟ੍ਰਿਕਪਲੇ ਪ੍ਰੀਵਿਊ ਬਣਾਉਂਦਾ ਹੈ (ਜੇ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਚੁਣਿਆ ਗਿਆ ਹੈ)।", + "TaskKeyframeExtractorDescription": "ਕੀ-ਫ੍ਰੇਮਜ਼ ਨੂੰ ਵੀਡੀਓ ਫਾਈਲਾਂ ਵਿੱਚੋਂ ਨਿਕਾਲਦਾ ਹੈ ਤਾਂ ਜੋ ਹੋਰ ਜ਼ਿਆਦਾ ਸਟਿਕ ਹੋਣ ਵਾਲੀਆਂ HLS ਪਲੇਲਿਸਟਾਂ ਬਣਾਈਆਂ ਜਾ ਸਕਣ। ਇਹ ਕੰਮ ਲੰਬੇ ਸਮੇਂ ਤੱਕ ਚੱਲ ਸਕਦਾ ਹੈ।", + "CleanupUserDataTaskDescription": "ਘੱਟੋ-ਘੱਟ 90 ਦਿਨਾਂ ਤੋਂ ਮੌਜੂਦ ਨਾ ਹੋਣ ਵਾਲੇ ਮੀਡੀਆ ਤੋਂ ਸਾਰੇ ਉਪਭੋਗਤਾ ਡੇਟਾ (ਵਾਚ ਸਟੇਟ, ਮਨਪਸੰਦ ਸਟੇਟਸ ਆਦਿ) ਨੂੰ ਸਾਫ਼ ਕਰਦਾ ਹੈ।", + "CleanupUserDataTask": "ਯੂਜ਼ਰ ਡਾਟਾ ਸਾਫ਼ ਕਰਨ ਦਾ ਕੰਮ" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-HK.json b/Emby.Server.Implementations/Localization/Core/zh-HK.json index 39141d8416..60af2274e4 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-HK.json +++ b/Emby.Server.Implementations/Localization/Core/zh-HK.json @@ -23,7 +23,7 @@ "HeaderFavoriteShows": "最愛的節目", "HeaderFavoriteSongs": "最愛的歌曲", "HeaderLiveTV": "電視直播", - "HeaderNextUp": "接著播放", + "HeaderNextUp": "繼續觀看", "HeaderRecordingGroups": "錄製組", "HomeVideos": "家庭影片", "Inherit": "繼承", @@ -137,5 +137,6 @@ "TaskCleanCollectionsAndPlaylistsDescription": "從資料庫及播放清單中移除已不存在的項目。", "TaskMoveTrickplayImagesDescription": "根據媒體庫設定移動現有的 Trickplay 檔案。", "TaskMoveTrickplayImages": "轉移 Trickplay 影像位置", - "CleanupUserDataTask": "用戶資料清理工作" + "CleanupUserDataTask": "用戶資料清理工作", + "CleanupUserDataTaskDescription": "從用戶數據中清除已經被刪除超過 90 日的媒體相關資料。" } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs index a28f280afd..36708e2582 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs @@ -261,14 +261,22 @@ public partial class AudioNormalizationTask : IScheduledTask using var reader = process.StandardError; float? lufs = null; + var foundLufs = false; await foreach (var line in reader.ReadAllLinesAsync(cancellationToken).ConfigureAwait(false)) { + if (foundLufs) + { + continue; + } + Match match = LUFSRegex().Match(line); - if (match.Success) + if (!match.Success) { - lufs = float.Parse(match.Groups[1].ValueSpan, CultureInfo.InvariantCulture.NumberFormat); - break; + continue; } + + lufs = float.Parse(match.Groups[1].ValueSpan, CultureInfo.InvariantCulture.NumberFormat); + foundLufs = true; } if (lufs is null) diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs index 678475b31f..5ff4001601 100644 --- a/Emby.Server.Implementations/Updates/InstallationManager.cs +++ b/Emby.Server.Implementations/Updates/InstallationManager.cs @@ -223,15 +223,14 @@ namespace Emby.Server.Implementations.Updates Guid id = default, Version? specificVersion = null) { - if (name is not null) - { - availablePackages = availablePackages.Where(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); - } - if (!id.IsEmpty()) { availablePackages = availablePackages.Where(x => x.Id.Equals(id)); } + else if (name is not null) + { + availablePackages = availablePackages.Where(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + } if (specificVersion is not null) { diff --git a/Jellyfin.Api/Formatters/XmlOutputFormatter.cs b/Jellyfin.Api/Formatters/XmlOutputFormatter.cs index 8dbb91d0aa..46256c09d7 100644 --- a/Jellyfin.Api/Formatters/XmlOutputFormatter.cs +++ b/Jellyfin.Api/Formatters/XmlOutputFormatter.cs @@ -1,4 +1,8 @@ +using System; using System.Net.Mime; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Formatters; namespace Jellyfin.Api.Formatters; @@ -6,7 +10,7 @@ namespace Jellyfin.Api.Formatters; /// <summary> /// Xml output formatter. /// </summary> -public sealed class XmlOutputFormatter : StringOutputFormatter +public sealed class XmlOutputFormatter : TextOutputFormatter { /// <summary> /// Initializes a new instance of the <see cref="XmlOutputFormatter"/> class. @@ -15,5 +19,24 @@ public sealed class XmlOutputFormatter : StringOutputFormatter { SupportedMediaTypes.Clear(); SupportedMediaTypes.Add(MediaTypeNames.Text.Xml); + + SupportedEncodings.Add(Encoding.UTF8); + SupportedEncodings.Add(Encoding.Unicode); + } + + /// <inheritdoc /> + public override async Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(selectedEncoding); + + var valueAsString = context.Object?.ToString(); + if (string.IsNullOrEmpty(valueAsString)) + { + return; + } + + var response = context.HttpContext.Response; + await response.WriteAsync(valueAsString, selectedEncoding).ConfigureAwait(false); } } diff --git a/Jellyfin.Data/Jellyfin.Data.csproj b/Jellyfin.Data/Jellyfin.Data.csproj index 45374c22f7..fd852ece93 100644 --- a/Jellyfin.Data/Jellyfin.Data.csproj +++ b/Jellyfin.Data/Jellyfin.Data.csproj @@ -18,7 +18,7 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Data</PackageId> - <VersionPrefix>10.11.0</VersionPrefix> + <VersionPrefix>10.12.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs index e5c3cef3d3..70483c36cc 100644 --- a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs +++ b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs @@ -128,7 +128,8 @@ public class BackupService : IBackupService var targetPath = Path.GetFullPath(Path.Combine(target, Path.GetRelativePath(source, item.FullName))); if (!sourcePath.StartsWith(fullSourcePath, StringComparison.Ordinal) - || !targetPath.StartsWith(fullTargetRoot, StringComparison.Ordinal)) + || !targetPath.StartsWith(fullTargetRoot, StringComparison.Ordinal) + || Path.EndsInDirectorySeparator(item.FullName)) { continue; } @@ -199,7 +200,7 @@ public class BackupService : IBackupService var zipEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.Type.Name}.json"))); if (zipEntry is null) { - _logger.LogInformation("No backup of expected table {Table} is present in backup. Continue anyway.", entityType.Type.Name); + _logger.LogInformation("No backup of expected table {Table} is present in backup, continuing anyway", entityType.Type.Name); continue; } @@ -223,7 +224,7 @@ public class BackupService : IBackupService } catch (Exception ex) { - _logger.LogError(ex, "Could not store entity {Entity} continue anyway.", item); + _logger.LogError(ex, "Could not store entity {Entity}, continuing anyway", item); } } @@ -233,11 +234,11 @@ public class BackupService : IBackupService _logger.LogInformation("Try restore Database"); await dbContext.SaveChangesAsync().ConfigureAwait(false); - _logger.LogInformation("Restored database."); + _logger.LogInformation("Restored database"); } } - _logger.LogInformation("Restored Jellyfin system from {Date}.", manifest.DateCreated); + _logger.LogInformation("Restored Jellyfin system from {Date}", manifest.DateCreated); } } @@ -263,6 +264,8 @@ public class BackupService : IBackupService Options = Map(backupOptions) }; + _logger.LogInformation("Running database optimization before backup"); + await _jellyfinDatabaseProvider.RunScheduledOptimisation(CancellationToken.None).ConfigureAwait(false); var backupFolder = Path.Combine(_applicationPaths.BackupPath); @@ -281,130 +284,154 @@ public class BackupService : IBackupService } var backupPath = Path.Combine(backupFolder, $"jellyfin-backup-{manifest.DateCreated.ToLocalTime():yyyyMMddHHmmss}.zip"); - _logger.LogInformation("Attempt to create a new backup at {BackupPath}", backupPath); - var fileStream = File.OpenWrite(backupPath); - await using (fileStream.ConfigureAwait(false)) - using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create, false)) + + try { - _logger.LogInformation("Start backup process."); - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) + _logger.LogInformation("Attempting to create a new backup at {BackupPath}", backupPath); + var fileStream = File.OpenWrite(backupPath); + await using (fileStream.ConfigureAwait(false)) + using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create, false)) { - dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; - static IAsyncEnumerable<object> GetValues(IQueryable dbSet) + _logger.LogInformation("Starting backup process"); + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) { - var method = dbSet.GetType().GetMethod(nameof(DbSet<object>.AsAsyncEnumerable))!; - var enumerable = method.Invoke(dbSet, null)!; - return (IAsyncEnumerable<object>)enumerable; - } + dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; - // include the migration history as well - var historyRepository = dbContext.GetService<IHistoryRepository>(); - var migrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false); - - ICollection<(Type Type, string SourceName, Func<IAsyncEnumerable<object>> ValueFactory)> entityTypes = [ - .. typeof(JellyfinDbContext) - .GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance) - .Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable))) - .Select(e => (Type: e.PropertyType, dbContext.Model.FindEntityType(e.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!, ValueFactory: new Func<IAsyncEnumerable<object>>(() => GetValues((IQueryable)e.GetValue(dbContext)!)))), - (Type: typeof(HistoryRow), SourceName: nameof(HistoryRow), ValueFactory: () => migrations.ToAsyncEnumerable()) - ]; - manifest.DatabaseTables = entityTypes.Select(e => e.Type.Name).ToArray(); - var transaction = await dbContext.Database.BeginTransactionAsync().ConfigureAwait(false); - - await using (transaction.ConfigureAwait(false)) - { - _logger.LogInformation("Begin Database backup"); + static IAsyncEnumerable<object> GetValues(IQueryable dbSet) + { + var method = dbSet.GetType().GetMethod(nameof(DbSet<object>.AsAsyncEnumerable))!; + var enumerable = method.Invoke(dbSet, null)!; + return (IAsyncEnumerable<object>)enumerable; + } - foreach (var entityType in entityTypes) + // include the migration history as well + var historyRepository = dbContext.GetService<IHistoryRepository>(); + var migrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false); + + ICollection<(Type Type, string SourceName, Func<IAsyncEnumerable<object>> ValueFactory)> entityTypes = + [ + .. typeof(JellyfinDbContext) + .GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance) + .Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable))) + .Select(e => (Type: e.PropertyType, dbContext.Model.FindEntityType(e.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!, ValueFactory: new Func<IAsyncEnumerable<object>>(() => GetValues((IQueryable)e.GetValue(dbContext)!)))), + (Type: typeof(HistoryRow), SourceName: nameof(HistoryRow), ValueFactory: () => migrations.ToAsyncEnumerable()) + ]; + manifest.DatabaseTables = entityTypes.Select(e => e.Type.Name).ToArray(); + var transaction = await dbContext.Database.BeginTransactionAsync().ConfigureAwait(false); + + await using (transaction.ConfigureAwait(false)) { - _logger.LogInformation("Begin backup of entity {Table}", entityType.SourceName); - var zipEntry = zipArchive.CreateEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.SourceName}.json"))); - var entities = 0; - var zipEntryStream = zipEntry.Open(); - await using (zipEntryStream.ConfigureAwait(false)) + _logger.LogInformation("Begin Database backup"); + + foreach (var entityType in entityTypes) { - var jsonSerializer = new Utf8JsonWriter(zipEntryStream); - await using (jsonSerializer.ConfigureAwait(false)) + _logger.LogInformation("Begin backup of entity {Table}", entityType.SourceName); + var zipEntry = zipArchive.CreateEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.SourceName}.json"))); + var entities = 0; + var zipEntryStream = zipEntry.Open(); + await using (zipEntryStream.ConfigureAwait(false)) { - jsonSerializer.WriteStartArray(); - - var set = entityType.ValueFactory().ConfigureAwait(false); - await foreach (var item in set.ConfigureAwait(false)) + var jsonSerializer = new Utf8JsonWriter(zipEntryStream); + await using (jsonSerializer.ConfigureAwait(false)) { - entities++; - try - { - JsonSerializer.SerializeToDocument(item, _serializerSettings).WriteTo(jsonSerializer); - } - catch (Exception ex) + jsonSerializer.WriteStartArray(); + + var set = entityType.ValueFactory().ConfigureAwait(false); + await foreach (var item in set.ConfigureAwait(false)) { - _logger.LogError(ex, "Could not load entity {Entity}", item); - throw; + entities++; + try + { + using var document = JsonSerializer.SerializeToDocument(item, _serializerSettings); + document.WriteTo(jsonSerializer); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not load entity {Entity}", item); + throw; + } } - } - jsonSerializer.WriteEndArray(); + jsonSerializer.WriteEndArray(); + } } - } - _logger.LogInformation("backup of entity {Table} with {Number} created", entityType.Type.Name, entities); + _logger.LogInformation("Backup of entity {Table} with {Number} created", entityType.SourceName, entities); + } } } - } - _logger.LogInformation("Backup of folder {Table}", _applicationPaths.ConfigurationDirectoryPath); - foreach (var item in Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.xml", SearchOption.TopDirectoryOnly) - .Union(Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.json", SearchOption.TopDirectoryOnly))) - { - zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine("Config", Path.GetFileName(item)))); - } + _logger.LogInformation("Backup of folder {Table}", _applicationPaths.ConfigurationDirectoryPath); + foreach (var item in Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.xml", SearchOption.TopDirectoryOnly) + .Union(Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.json", SearchOption.TopDirectoryOnly))) + { + zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine("Config", Path.GetFileName(item)))); + } - void CopyDirectory(string source, string target, string filter = "*") - { - if (!Directory.Exists(source)) + void CopyDirectory(string source, string target, string filter = "*") { - return; + if (!Directory.Exists(source)) + { + return; + } + + _logger.LogInformation("Backup of folder {Table}", source); + + foreach (var item in Directory.EnumerateFiles(source, filter, SearchOption.AllDirectories)) + { + zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine(target, Path.GetRelativePath(source, item)))); + } } - _logger.LogInformation("Backup of folder {Table}", source); + CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "users"), Path.Combine("Config", "users")); + CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "ScheduledTasks"), Path.Combine("Config", "ScheduledTasks")); + CopyDirectory(Path.Combine(_applicationPaths.RootFolderPath), "Root"); + CopyDirectory(Path.Combine(_applicationPaths.DataPath, "collections"), Path.Combine("Data", "collections")); + CopyDirectory(Path.Combine(_applicationPaths.DataPath, "playlists"), Path.Combine("Data", "playlists")); + CopyDirectory(Path.Combine(_applicationPaths.DataPath, "ScheduledTasks"), Path.Combine("Data", "ScheduledTasks")); + if (backupOptions.Subtitles) + { + CopyDirectory(Path.Combine(_applicationPaths.DataPath, "subtitles"), Path.Combine("Data", "subtitles")); + } - foreach (var item in Directory.EnumerateFiles(source, filter, SearchOption.AllDirectories)) + if (backupOptions.Trickplay) { - zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine(target, Path.GetRelativePath(source, item)))); + CopyDirectory(Path.Combine(_applicationPaths.DataPath, "trickplay"), Path.Combine("Data", "trickplay")); } - } - CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "users"), Path.Combine("Config", "users")); - CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "ScheduledTasks"), Path.Combine("Config", "ScheduledTasks")); - CopyDirectory(Path.Combine(_applicationPaths.RootFolderPath), "Root"); - CopyDirectory(Path.Combine(_applicationPaths.DataPath, "collections"), Path.Combine("Data", "collections")); - CopyDirectory(Path.Combine(_applicationPaths.DataPath, "playlists"), Path.Combine("Data", "playlists")); - CopyDirectory(Path.Combine(_applicationPaths.DataPath, "ScheduledTasks"), Path.Combine("Data", "ScheduledTasks")); - if (backupOptions.Subtitles) - { - CopyDirectory(Path.Combine(_applicationPaths.DataPath, "subtitles"), Path.Combine("Data", "subtitles")); - } + if (backupOptions.Metadata) + { + CopyDirectory(Path.Combine(_applicationPaths.InternalMetadataPath), Path.Combine("Data", "metadata")); + } - if (backupOptions.Trickplay) - { - CopyDirectory(Path.Combine(_applicationPaths.DataPath, "trickplay"), Path.Combine("Data", "trickplay")); + var manifestStream = zipArchive.CreateEntry(ManifestEntryName).Open(); + await using (manifestStream.ConfigureAwait(false)) + { + await JsonSerializer.SerializeAsync(manifestStream, manifest).ConfigureAwait(false); + } } - if (backupOptions.Metadata) + _logger.LogInformation("Backup created"); + return Map(manifest, backupPath); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create backup, removing {BackupPath}", backupPath); + try { - CopyDirectory(Path.Combine(_applicationPaths.InternalMetadataPath), Path.Combine("Data", "metadata")); + if (File.Exists(backupPath)) + { + File.Delete(backupPath); + } } - - var manifestStream = zipArchive.CreateEntry(ManifestEntryName).Open(); - await using (manifestStream.ConfigureAwait(false)) + catch (Exception innerEx) { - await JsonSerializer.SerializeAsync(manifestStream, manifest).ConfigureAwait(false); + _logger.LogWarning(innerEx, "Unable to remove failed backup"); } - } - _logger.LogInformation("Backup created"); - return Map(manifest, backupPath); + throw; + } } /// <inheritdoc/> @@ -422,7 +449,7 @@ public class BackupService : IBackupService } catch (Exception ex) { - _logger.LogError(ex, "Tried to load archive from {Path} but failed.", archivePath); + _logger.LogWarning(ex, "Tried to load manifest from archive {Path} but failed", archivePath); return null; } @@ -459,7 +486,7 @@ public class BackupService : IBackupService } catch (Exception ex) { - _logger.LogError(ex, "Could not load {BackupArchive} path.", item); + _logger.LogWarning(ex, "Tried to load manifest from archive {Path} but failed", item); } } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs index ef444b9302..b939c4ab21 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs @@ -614,6 +614,13 @@ public sealed class BaseItemRepository else { context.BaseItemProviders.Where(e => e.ItemId == entity.Id).ExecuteDelete(); + context.BaseItemImageInfos.Where(e => e.ItemId == entity.Id).ExecuteDelete(); + + if (entity.Images is { Count: > 0 }) + { + context.BaseItemImageInfos.AddRange(entity.Images); + } + context.BaseItems.Attach(entity).State = EntityState.Modified; } } @@ -1232,8 +1239,20 @@ public sealed class BaseItemRepository ExcludeItemIds = filter.ExcludeItemIds }; - var query = TranslateQuery(innerQuery, context, outerQueryFilter) - .GroupBy(e => e.PresentationUniqueKey); + var masterQuery = TranslateQuery(innerQuery, context, outerQueryFilter) + .GroupBy(e => e.PresentationUniqueKey) + .Select(e => e.FirstOrDefault()) + .Select(e => e!.Id); + + var query = context.BaseItems + .Include(e => e.TrailerTypes) + .Include(e => e.Provider) + .Include(e => e.LockedFields) + .Include(e => e.Images) + .AsSingleQuery() + .Where(e => masterQuery.Contains(e.Id)); + + query = ApplyOrder(query, filter); var result = new QueryResult<(BaseItemDto, ItemCounts?)>(); if (filter.EnableTotalRecordCount) @@ -1288,12 +1307,7 @@ public sealed class BaseItemRepository var resultQuery = query.Select(e => new { - item = e.AsQueryable() - .Include(e => e.TrailerTypes) - .Include(e => e.Provider) - .Include(e => e.LockedFields) - .Include(e => e.Images) - .AsSingleQuery().First(), + item = e, // TODO: This is bad refactor! itemCount = new ItemCounts() { @@ -1325,13 +1339,6 @@ public sealed class BaseItemRepository result.Items = [ .. query - .Select(e => e.AsQueryable() - .Include(e => e.TrailerTypes) - .Include(e => e.Provider) - .Include(e => e.LockedFields) - .Include(e => e.Images) - .AsSingleQuery() - .First()) .AsEnumerable() .Where(e => e is not null) .Select<BaseItemEntity, (BaseItemDto, ItemCounts?)>(e => @@ -1756,7 +1763,8 @@ public sealed class BaseItemRepository if (!string.IsNullOrWhiteSpace(filter.Path)) { - baseQuery = baseQuery.Where(e => e.Path == filter.Path); + var pathToQuery = GetPathToSave(filter.Path); + baseQuery = baseQuery.Where(e => e.Path == pathToQuery); } if (!string.IsNullOrWhiteSpace(filter.PresentationUniqueKey)) @@ -2014,7 +2022,7 @@ public sealed class BaseItemRepository if (filter.ArtistIds.Length > 0) { - baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Artist, filter.ArtistIds); + baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ArtistIds); } if (filter.AlbumArtistIds.Length > 0) @@ -2024,7 +2032,18 @@ public sealed class BaseItemRepository if (filter.ContributingArtistIds.Length > 0) { - baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Artist, filter.ContributingArtistIds); + var contributingNames = context.BaseItems + .Where(b => filter.ContributingArtistIds.Contains(b.Id)) + .Select(b => b.CleanName); + + baseQuery = baseQuery.Where(e => + e.ItemValues!.Any(ivm => + ivm.ItemValue.Type == ItemValueType.Artist && + contributingNames.Contains(ivm.ItemValue.CleanValue)) + && + !e.ItemValues!.Any(ivm => + ivm.ItemValue.Type == ItemValueType.AlbumArtist && + contributingNames.Contains(ivm.ItemValue.CleanValue))); } if (filter.AlbumIds.Length > 0) @@ -2035,7 +2054,7 @@ public sealed class BaseItemRepository if (filter.ExcludeArtistIds.Length > 0) { - baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Artist, filter.ExcludeArtistIds, true); + baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ExcludeArtistIds, true); } if (filter.GenreIds.Count > 0) @@ -2342,17 +2361,23 @@ public sealed class BaseItemRepository if (filter.HasImdbId.HasValue) { - baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "imdb")); + baseQuery = filter.HasImdbId.Value + ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Imdb.ToString().ToLower())) + : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Imdb.ToString().ToLower())); } if (filter.HasTmdbId.HasValue) { - baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "tmdb")); + baseQuery = filter.HasTmdbId.Value + ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Tmdb.ToString().ToLower())) + : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Tmdb.ToString().ToLower())); } if (filter.HasTvdbId.HasValue) { - baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "tvdb")); + baseQuery = filter.HasTvdbId.Value + ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Tvdb.ToString().ToLower())) + : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Tvdb.ToString().ToLower())); } var queryTopParentIds = filter.TopParentIds; diff --git a/Jellyfin.Server.Implementations/Item/ChapterRepository.cs b/Jellyfin.Server.Implementations/Item/ChapterRepository.cs index e0d23a2613..98700f3224 100644 --- a/Jellyfin.Server.Implementations/Item/ChapterRepository.cs +++ b/Jellyfin.Server.Implementations/Item/ChapterRepository.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Drawing; @@ -82,11 +84,14 @@ public class ChapterRepository : IChapterRepository } /// <inheritdoc /> - public void DeleteChapters(Guid itemId) + public async Task DeleteChaptersAsync(Guid itemId, CancellationToken cancellationToken) { - using var context = _dbProvider.CreateDbContext(); - context.Chapters.Where(c => c.ItemId.Equals(itemId)).ExecuteDelete(); - context.SaveChanges(); + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + await dbContext.Chapters.Where(c => c.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } } private Chapter Map(ChapterInfo chapterInfo, int index, Guid itemId) diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs index e03c136915..355ed64797 100644 --- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -95,6 +95,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I .ToArray(); var toAdd = people + .Where(e => e.Type is not PersonKind.Artist && e.Type is not PersonKind.AlbumArtist) .Where(e => !existingPersons.Any(f => f.Name == e.Name && f.PersonType == e.Type.ToString())) .Select(Map); context.Peoples.AddRange(toAdd); @@ -108,6 +109,11 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I foreach (var person in people) { + if (person.Type == PersonKind.Artist || person.Type == PersonKind.AlbumArtist) + { + continue; + } + var entityPerson = personsEntities.First(e => e.Name == person.Name && e.PersonType == person.Type.ToString()); var existingMap = existingMaps.FirstOrDefault(e => e.People.Name == person.Name && e.People.PersonType == person.Type.ToString() && e.Role == person.Role); if (existingMap is null) diff --git a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs index b2f54be7e2..570d6cb9b7 100644 --- a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs +++ b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs @@ -14,7 +14,7 @@ public static class StorageHelper { private const long TwoGigabyte = 2_147_483_647L; private const long FiveHundredAndTwelveMegaByte = 536_870_911L; - private static readonly string[] _byteHumanizedSuffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB"]; + private static readonly string[] _byteHumanizedSuffixes = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"]; /// <summary> /// Tests the available storage capacity on the jellyfin paths with estimated minimum values. @@ -27,7 +27,7 @@ public static class StorageHelper TestDataDirectorySize(applicationPaths.LogDirectoryPath, logger, FiveHundredAndTwelveMegaByte); TestDataDirectorySize(applicationPaths.CachePath, logger, TwoGigabyte); TestDataDirectorySize(applicationPaths.ProgramDataPath, logger, TwoGigabyte); - TestDataDirectorySize(applicationPaths.TempDirectory, logger, TwoGigabyte); + TestDataDirectorySize(applicationPaths.TempDirectory, logger, FiveHundredAndTwelveMegaByte); } /// <summary> @@ -77,7 +77,7 @@ public static class StorageHelper var drive = new DriveInfo(path); if (threshold != -1 && drive.AvailableFreeSpace < threshold) { - throw new InvalidOperationException($"The path `{path}` has insufficient free space. Required: at least {HumanizeStorageSize(threshold)}."); + throw new InvalidOperationException($"The path `{path}` has insufficient free space. Available: {HumanizeStorageSize(drive.AvailableFreeSpace)}, Required: {HumanizeStorageSize(threshold)}."); } logger.LogInformation( diff --git a/Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs b/Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs index 08caac0d38..8b7268513a 100644 --- a/Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs +++ b/Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs @@ -8,7 +8,7 @@ internal class RetryOnTemporarilyUnavailableFilter : IOperationFilter { public void Apply(OpenApiOperation operation, OperationFilterContext context) { - operation.Responses.Add( + operation.Responses.TryAdd( "503", new OpenApiResponse { diff --git a/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs b/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs index 401392a633..8f57572696 100644 --- a/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs +++ b/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs @@ -66,15 +66,8 @@ public class SecurityRequirementsOperationFilter : IOperationFilter return; } - if (!operation.Responses.ContainsKey("401")) - { - operation.Responses.Add("401", new OpenApiResponse { Description = "Unauthorized" }); - } - - if (!operation.Responses.ContainsKey("403")) - { - operation.Responses.Add("403", new OpenApiResponse { Description = "Forbidden" }); - } + operation.Responses.TryAdd("401", new OpenApiResponse { Description = "Unauthorized" }); + operation.Responses.TryAdd("403", new OpenApiResponse { Description = "Forbidden" }); var scheme = new OpenApiSecurityScheme { diff --git a/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs b/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs deleted file mode 100644 index 910b5c4672..0000000000 --- a/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs +++ /dev/null @@ -1,151 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) .NET Foundation and Contributors -// -// All rights reserved. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Extensions; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Infrastructure; -using Microsoft.Extensions.Logging; -using Microsoft.Net.Http.Headers; - -namespace Jellyfin.Server.Infrastructure -{ - /// <inheritdoc /> - public class SymlinkFollowingPhysicalFileResultExecutor : PhysicalFileResultExecutor - { - /// <summary> - /// Initializes a new instance of the <see cref="SymlinkFollowingPhysicalFileResultExecutor"/> class. - /// </summary> - /// <param name="loggerFactory">An instance of the <see cref="ILoggerFactory"/> interface.</param> - public SymlinkFollowingPhysicalFileResultExecutor(ILoggerFactory loggerFactory) : base(loggerFactory) - { - } - - /// <inheritdoc /> - protected override FileMetadata GetFileInfo(string path) - { - var fileInfo = new FileInfo(path); - var length = fileInfo.Length; - // This may or may not be fixed in .NET 6, but looks like it will not https://github.com/dotnet/aspnetcore/issues/34371 - if ((fileInfo.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint) - { - using var fileHandle = File.OpenHandle(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - length = RandomAccess.GetLength(fileHandle); - } - - return new FileMetadata - { - Exists = fileInfo.Exists, - Length = length, - LastModified = fileInfo.LastWriteTimeUtc - }; - } - - /// <inheritdoc /> - protected override async Task WriteFileAsync(ActionContext context, PhysicalFileResult result, RangeItemHeaderValue? range, long rangeLength) - { - ArgumentNullException.ThrowIfNull(context); - ArgumentNullException.ThrowIfNull(result); - - if (range is not null && rangeLength == 0) - { - return; - } - - // It's a bit of wasted IO to perform this check again, but non-symlinks shouldn't use this code - if (!IsSymLink(result.FileName)) - { - await base.WriteFileAsync(context, result, range, rangeLength).ConfigureAwait(false); - return; - } - - var response = context.HttpContext.Response; - - if (range is not null) - { - await SendFileAsync( - result.FileName, - response, - offset: range.From ?? 0L, - count: rangeLength).ConfigureAwait(false); - return; - } - - await SendFileAsync( - result.FileName, - response, - offset: 0, - count: null).ConfigureAwait(false); - } - - private async Task SendFileAsync(string filePath, HttpResponse response, long offset, long? count, CancellationToken cancellationToken = default) - { - var fileInfo = GetFileInfo(filePath); - if (offset < 0 || offset > fileInfo.Length) - { - throw new ArgumentOutOfRangeException(nameof(offset), offset, string.Empty); - } - - if (count.HasValue - && (count.Value < 0 || count.Value > fileInfo.Length - offset)) - { - throw new ArgumentOutOfRangeException(nameof(count), count, string.Empty); - } - - // Copied from SendFileFallback.SendFileAsync - const int BufferSize = 1024 * 16; - - var useRequestAborted = !cancellationToken.CanBeCanceled; - var localCancel = useRequestAborted ? response.HttpContext.RequestAborted : cancellationToken; - - var fileStream = new FileStream( - filePath, - FileMode.Open, - FileAccess.Read, - FileShare.ReadWrite, - bufferSize: BufferSize, - options: FileOptions.Asynchronous | FileOptions.SequentialScan); - await using (fileStream.ConfigureAwait(false)) - { - try - { - localCancel.ThrowIfCancellationRequested(); - fileStream.Seek(offset, SeekOrigin.Begin); - await StreamCopyOperation - .CopyToAsync(fileStream, response.Body, count, BufferSize, localCancel) - .ConfigureAwait(true); - } - catch (OperationCanceledException) when (useRequestAborted) - { - } - } - } - - private static bool IsSymLink(string path) => (File.GetAttributes(path) & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint; - } -} diff --git a/Jellyfin.Server/Migrations/Routines/CleanMusicArtist.cs b/Jellyfin.Server/Migrations/Routines/CleanMusicArtist.cs new file mode 100644 index 0000000000..d5c5f3d929 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/CleanMusicArtist.cs @@ -0,0 +1,47 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.ServerSetupApp; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Cleans up all Music artists that have been migrated in the 10.11 RC migrations. +/// </summary> +[JellyfinMigration("2025-10-09T20:00:00", nameof(CleanMusicArtist))] +[JellyfinMigrationBackup(JellyfinDb = true)] +public class CleanMusicArtist : IAsyncMigrationRoutine +{ + private readonly IStartupLogger<CleanMusicArtist> _startupLogger; + private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory; + + /// <summary> + /// Initializes a new instance of the <see cref="CleanMusicArtist"/> class. + /// </summary> + /// <param name="startupLogger">The startup logger.</param> + /// <param name="dbContextFactory">The Db context factory.</param> + public CleanMusicArtist(IStartupLogger<CleanMusicArtist> startupLogger, IDbContextFactory<JellyfinDbContext> dbContextFactory) + { + _startupLogger = startupLogger; + _dbContextFactory = dbContextFactory; + } + + /// <inheritdoc/> + public async Task PerformAsync(CancellationToken cancellationToken) + { + var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + var peoples = context.Peoples.Where(e => e.PersonType == nameof(PersonKind.Artist) || e.PersonType == nameof(PersonKind.AlbumArtist)); + _startupLogger.LogInformation("Delete {Number} Artist and Album Artist person types from db", await peoples.CountAsync(cancellationToken).ConfigureAwait(false)); + + await peoples + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs index a954d307e1..b36db347cd 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs @@ -55,9 +55,25 @@ namespace Jellyfin.Server.Migrations.Routines }; var dataPath = _paths.DataPath; - using (var connection = new SqliteConnection($"Filename={Path.Combine(dataPath, DbFilename)}")) + var activityLogPath = Path.Combine(dataPath, DbFilename); + if (!File.Exists(activityLogPath)) + { + _logger.LogWarning("{ActivityLogDb} doesn't exist, nothing to migrate", activityLogPath); + return; + } + + using (var connection = new SqliteConnection($"Filename={activityLogPath}")) { connection.Open(); + var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='ActivityLog';"); + foreach (var row in tableQuery) + { + if (row.GetInt32(0) == 0) + { + _logger.LogWarning("Table 'ActivityLog' doesn't exist in {ActivityLogPath}, nothing to migrate", activityLogPath); + break; + } + } using var userDbConnection = new SqliteConnection($"Filename={Path.Combine(dataPath, "users.db")}"); userDbConnection.Open(); diff --git a/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs b/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs index c199ee4d6b..aa55309264 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs @@ -122,6 +122,16 @@ public class MigrateKeyframeData : IDatabaseMigrationRoutine { lastWriteTimeUtc = File.GetLastWriteTimeUtc(filePath); } + catch (ArgumentOutOfRangeException e) + { + _logger.LogDebug("Skipping {Path}: {Exception}", filePath, e.Message); + return null; + } + catch (UnauthorizedAccessException e) + { + _logger.LogDebug("Skipping {Path}: {Exception}", filePath, e.Message); + return null; + } catch (IOException e) { _logger.LogDebug("Skipping {Path}: {Exception}", filePath, e.Message); @@ -135,14 +145,21 @@ public class MigrateKeyframeData : IDatabaseMigrationRoutine return Path.Join(keyframeCachePath, prefix, filename); } - private static bool TryReadFromCache(string? cachePath, [NotNullWhen(true)] out MediaEncoding.Keyframes.KeyframeData? cachedResult) + private bool TryReadFromCache(string? cachePath, [NotNullWhen(true)] out MediaEncoding.Keyframes.KeyframeData? cachedResult) { if (File.Exists(cachePath)) { - var bytes = File.ReadAllBytes(cachePath); - cachedResult = JsonSerializer.Deserialize<MediaEncoding.Keyframes.KeyframeData>(bytes, _jsonOptions); + try + { + var bytes = File.ReadAllBytes(cachePath); + cachedResult = JsonSerializer.Deserialize<MediaEncoding.Keyframes.KeyframeData>(bytes, _jsonOptions); - return cachedResult is not null; + return cachedResult is not null; + } + catch (JsonException jsonException) + { + _logger.LogWarning(jsonException, "Failed to read {Path}", cachePath); + } } cachedResult = null; diff --git a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs index e5584fb947..c3f07c0899 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs @@ -57,11 +57,28 @@ public class MigrateUserDb : IMigrationRoutine public void Perform() { var dataPath = _paths.DataPath; + var userDbPath = Path.Combine(dataPath, DbFilename); + if (!File.Exists(userDbPath)) + { + _logger.LogWarning("{UserDbPath} doesn't exist, nothing to migrate", userDbPath); + return; + } + _logger.LogInformation("Migrating the user database may take a while, do not stop Jellyfin."); - using (var connection = new SqliteConnection($"Filename={Path.Combine(dataPath, DbFilename)}")) + using (var connection = new SqliteConnection($"Filename={userDbPath}")) { connection.Open(); + var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='LocalUsersv2';"); + foreach (var row in tableQuery) + { + if (row.GetInt32(0) == 0) + { + _logger.LogWarning("Table 'LocalUsersv2' doesn't exist in {UserDbPath}, nothing to migrate", userDbPath); + break; + } + } + using var dbContext = _provider.CreateDbContext(); var queryResult = connection.Query("SELECT * FROM LocalUsersv2"); diff --git a/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs b/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs index 8b394dd7aa..fbf9c16377 100644 --- a/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs +++ b/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs @@ -224,6 +224,18 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine return null; } + catch (UnauthorizedAccessException e) + { + _logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", attachmentStreamIndex, mediaPath, e.Message); + + return null; + } + catch (ArgumentOutOfRangeException e) + { + _logger.LogDebug("Skipping attachment at index {Index} for {Path}: {Exception}", attachmentStreamIndex, mediaPath, e.Message); + + return null; + } filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Value.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D", CultureInfo.InvariantCulture); } @@ -263,6 +275,18 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine { date = File.GetLastWriteTimeUtc(path); } + catch (ArgumentOutOfRangeException e) + { + _logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", streamIndex, path, e.Message); + + return null; + } + catch (UnauthorizedAccessException e) + { + _logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", streamIndex, path, e.Message); + + return null; + } catch (IOException e) { _logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", streamIndex, path, e.Message); diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index dc7fa5eb36..93f71fdc69 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -184,6 +184,12 @@ namespace Jellyfin.Server .AddSingleton<IServiceCollection>(e)) .Build(); + /* + * Initialize the transcode path marker so we avoid starting Jellyfin in a broken state. + * This should really be a part of IApplicationPaths but this path is configured differently. + */ + _ = appHost.ConfigurationManager.GetTranscodePath(); + // Re-use the host service provider in the app host since ASP.NET doesn't allow a custom service collection. appHost.ServiceProvider = _jellyfinHost.Services; PrepareDatabaseProvider(appHost.ServiceProvider); diff --git a/Jellyfin.Server/ServerSetupApp/SetupServer.cs b/Jellyfin.Server/ServerSetupApp/SetupServer.cs index 72626e8532..00d9fcc025 100644 --- a/Jellyfin.Server/ServerSetupApp/SetupServer.cs +++ b/Jellyfin.Server/ServerSetupApp/SetupServer.cs @@ -250,6 +250,7 @@ public sealed class SetupServer : IDisposable { "isInReportingMode", _isUnhealthy }, { "retryValue", retryAfterValue }, { "logs", startupLogEntries }, + { "networkManagerReady", networkManager is not null }, { "localNetworkRequest", networkManager is not null && context.Connection.RemoteIpAddress is not null && networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress) } }, new ByteCounterStream(context.Response.BodyWriter.AsStream(), IODefaults.FileStreamBufferSize, true, _startupUiRenderer.ParserOptions)) diff --git a/Jellyfin.Server/ServerSetupApp/index.mstemplate.html b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html index 523f38d74a..9ec6efa2b9 100644 --- a/Jellyfin.Server/ServerSetupApp/index.mstemplate.html +++ b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html @@ -213,7 +213,12 @@ </ol> </div> {{#ELSE}} + {{#IF networkManagerReady}} <p>Please visit this page from your local network to view detailed startup logs.</p> + {{#ELSE}} + <p>Initializing network settings. Please wait.</p> + {{/ELSE}} + {{/IF}} {{/ELSE}} {{/IF}} </div> diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index aa8f6dd1cd..5032b2aec1 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -16,15 +16,12 @@ using Jellyfin.Networking.HappyEyeballs; using Jellyfin.Server.Extensions; using Jellyfin.Server.HealthChecks; using Jellyfin.Server.Implementations.Extensions; -using Jellyfin.Server.Infrastructure; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Extensions; using MediaBrowser.XbmcMetadata; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -69,8 +66,6 @@ namespace Jellyfin.Server options.HttpsPort = _serverApplicationHost.HttpsPort; }); - // 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(_serverApplicationHost.ConfigurationManager, _configuration); services.AddJellyfinApiSwagger(); diff --git a/MediaBrowser.Common/MediaBrowser.Common.csproj b/MediaBrowser.Common/MediaBrowser.Common.csproj index de6be4707e..9af13b0a72 100644 --- a/MediaBrowser.Common/MediaBrowser.Common.csproj +++ b/MediaBrowser.Common/MediaBrowser.Common.csproj @@ -8,7 +8,7 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Common</PackageId> - <VersionPrefix>10.11.0</VersionPrefix> + <VersionPrefix>10.12.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> diff --git a/MediaBrowser.Controller/Chapters/IChapterManager.cs b/MediaBrowser.Controller/Chapters/IChapterManager.cs index 7532e56c60..25656fd625 100644 --- a/MediaBrowser.Controller/Chapters/IChapterManager.cs +++ b/MediaBrowser.Controller/Chapters/IChapterManager.cs @@ -48,8 +48,10 @@ public interface IChapterManager Task<bool> RefreshChapterImages(Video video, IDirectoryService directoryService, IReadOnlyList<ChapterInfo> chapters, bool extractImages, bool saveChapters, CancellationToken cancellationToken); /// <summary> - /// Deletes the chapter images. + /// Deletes the chapter data. /// </summary> - /// <param name="video">Video to use.</param> - void DeleteChapterImages(Video video); + /// <param name="itemId">The item id.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + Task DeleteChapterDataAsync(Guid itemId, CancellationToken cancellationToken); } diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index e62004510f..03ee447088 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -457,6 +457,12 @@ namespace MediaBrowser.Controller.Entities { foreach (var item in itemsRemoved) { + if (!item.CanDelete()) + { + Logger.LogDebug("Item marked as non-removable, skipping: {Path}", item.Path ?? item.Name); + continue; + } + if (item.IsFileProtocol) { Logger.LogDebug("Removed item: {Path}", item.Path); @@ -709,9 +715,18 @@ namespace MediaBrowser.Controller.Entities } else { - items = GetRecursiveChildren(user, query, out totalCount); + // Save pagination params before clearing them to prevent pagination from happening + // before sorting. PostFilterAndSort will apply pagination after sorting. + var limit = query.Limit; + var startIndex = query.StartIndex; query.Limit = null; - query.StartIndex = null; // override these here as they have already been applied + query.StartIndex = null; + + items = GetRecursiveChildren(user, query, out totalCount); + + // Restore pagination params so PostFilterAndSort can apply them after sorting + query.Limit = limit; + query.StartIndex = startIndex; } var result = PostFilterAndSort(items, query); @@ -974,20 +989,16 @@ namespace MediaBrowser.Controller.Entities else { // need to pass this param to the children. + // Note: Don't pass Limit/StartIndex here as pagination should happen after sorting in PostFilterAndSort var childQuery = new InternalItemsQuery { DisplayAlbumFolders = query.DisplayAlbumFolders, - Limit = query.Limit, - StartIndex = query.StartIndex, NameStartsWith = query.NameStartsWith, NameStartsWithOrGreater = query.NameStartsWithOrGreater, NameLessThan = query.NameLessThan }; items = GetChildren(user, true, out totalItemCount, childQuery).Where(filter); - - query.Limit = null; - query.StartIndex = null; } var result = PostFilterAndSort(items, query); diff --git a/MediaBrowser.Controller/MediaBrowser.Controller.csproj b/MediaBrowser.Controller/MediaBrowser.Controller.csproj index 3353ad63f1..b5d14e94b1 100644 --- a/MediaBrowser.Controller/MediaBrowser.Controller.csproj +++ b/MediaBrowser.Controller/MediaBrowser.Controller.csproj @@ -8,7 +8,7 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Controller</PackageId> - <VersionPrefix>10.11.0</VersionPrefix> + <VersionPrefix>10.12.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index c81e639a22..a1d8915353 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -2390,8 +2390,8 @@ namespace MediaBrowser.Controller.MediaEncoding || (requestHasSDR && videoStream.VideoRangeType == VideoRangeType.DOVIWithSDR) || (requestHasHDR10 && videoStream.VideoRangeType == VideoRangeType.HDR10Plus))) { - // If the video stream is in a static HDR format, don't allow copy if the client does not support HDR10 or HLG. - if (videoStream.VideoRangeType is VideoRangeType.HDR10 or VideoRangeType.HLG) + // If the video stream is in HDR10+ or a static HDR format, don't allow copy if the client does not support HDR10 or HLG. + if (videoStream.VideoRangeType is VideoRangeType.HDR10Plus or VideoRangeType.HDR10 or VideoRangeType.HLG) { return false; } diff --git a/MediaBrowser.Controller/Persistence/IChapterRepository.cs b/MediaBrowser.Controller/Persistence/IChapterRepository.cs index 0844ddb364..64b90fd638 100644 --- a/MediaBrowser.Controller/Persistence/IChapterRepository.cs +++ b/MediaBrowser.Controller/Persistence/IChapterRepository.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; using MediaBrowser.Model.Entities; namespace MediaBrowser.Controller.Persistence; @@ -13,7 +15,9 @@ public interface IChapterRepository /// Deletes the chapters. /// </summary> /// <param name="itemId">The item.</param> - void DeleteChapters(Guid itemId); + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + Task DeleteChaptersAsync(Guid itemId, CancellationToken cancellationToken); /// <summary> /// Saves the chapters. diff --git a/MediaBrowser.MediaEncoding/Configuration/EncodingConfigurationStore.cs b/MediaBrowser.MediaEncoding/Configuration/EncodingConfigurationStore.cs index 2f158157e8..19c1de9f74 100644 --- a/MediaBrowser.MediaEncoding/Configuration/EncodingConfigurationStore.cs +++ b/MediaBrowser.MediaEncoding/Configuration/EncodingConfigurationStore.cs @@ -18,10 +18,16 @@ namespace MediaBrowser.MediaEncoding.Configuration public void Validate(object oldConfig, object newConfig) { - var newPath = ((EncodingOptions)newConfig).TranscodingTempPath; + var oldEncodingOptions = (EncodingOptions)oldConfig; + var newEncodingOptions = (EncodingOptions)newConfig; + + ArgumentNullException.ThrowIfNull(oldEncodingOptions, nameof(oldConfig)); + ArgumentNullException.ThrowIfNull(newEncodingOptions, nameof(newConfig)); + + var newPath = newEncodingOptions.TranscodingTempPath; if (!string.IsNullOrWhiteSpace(newPath) - && !string.Equals(((EncodingOptions)oldConfig).TranscodingTempPath, newPath, StringComparison.Ordinal)) + && !string.Equals(oldEncodingOptions.TranscodingTempPath, newPath, StringComparison.Ordinal)) { // Validate if (!Directory.Exists(newPath)) @@ -33,6 +39,12 @@ namespace MediaBrowser.MediaEncoding.Configuration newPath)); } } + + if (!string.IsNullOrWhiteSpace(newEncodingOptions.EncoderAppPath) + && !string.Equals(oldEncodingOptions.EncoderAppPath, newEncodingOptions.EncoderAppPath, StringComparison.Ordinal)) + { + throw new InvalidOperationException("Unable to update encoder app path."); + } } } } diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index 8350d1613b..b7fef842b3 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -1122,7 +1122,15 @@ namespace MediaBrowser.MediaEncoding.Encoder private void StartProcess(ProcessWrapper process) { process.Process.Start(); - process.Process.PriorityClass = ProcessPriorityClass.BelowNormal; + + try + { + process.Process.PriorityClass = ProcessPriorityClass.BelowNormal; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Unable to set process priority to BelowNormal for {ProcessFileName}", process.Process.StartInfo.FileName); + } lock (_runningProcessesLock) { diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index 00a9ae797d..eb312029a1 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -930,6 +930,15 @@ namespace MediaBrowser.MediaEncoding.Probing { stream.Rotation = data.Rotation; } + + // Parse video frame cropping metadata from side_data + // TODO: save them and make HW filters to apply them in HWA pipelines + else if (string.Equals(data.SideDataType, "Frame Cropping", StringComparison.OrdinalIgnoreCase)) + { + // Streams containing artificially added frame cropping + // metadata should not be marked as anamorphic. + stream.IsAnamorphic = false; + } } } diff --git a/MediaBrowser.Model/MediaBrowser.Model.csproj b/MediaBrowser.Model/MediaBrowser.Model.csproj index e9dab6bc8a..ef025d02dc 100644 --- a/MediaBrowser.Model/MediaBrowser.Model.csproj +++ b/MediaBrowser.Model/MediaBrowser.Model.csproj @@ -8,7 +8,7 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Model</PackageId> - <VersionPrefix>10.11.0</VersionPrefix> + <VersionPrefix>10.12.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs index ad9edb031c..82c6e3011a 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs @@ -138,6 +138,8 @@ namespace MediaBrowser.Providers.Plugins.Omdb } var item = itemResult.Item; + item.IndexNumber = episodeNumber; + item.ParentIndexNumber = seasonNumber; var seasonResult = await GetSeasonRootObject(seriesImdbId, seasonNumber, cancellationToken).ConfigureAwait(false); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs index 18cdba7a00..02818a0e24 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs @@ -66,7 +66,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets var language = item.GetPreferredMetadataLanguage(); // TODO use image languages if All Languages isn't toggled, but there's currently no way to get that value in here - var collection = await _tmdbClientManager.GetCollectionAsync(tmdbId, null, null, cancellationToken).ConfigureAwait(false); + var collection = await _tmdbClientManager.GetCollectionAsync(tmdbId, null, null, null, cancellationToken).ConfigureAwait(false); if (collection?.Images is null) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs index c76c65591f..34c9abae12 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs @@ -47,7 +47,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets if (tmdbId > 0) { - var collection = await _tmdbClientManager.GetCollectionAsync(tmdbId, language, TmdbUtils.GetImageLanguagesParam(language), cancellationToken).ConfigureAwait(false); + var collection = await _tmdbClientManager.GetCollectionAsync(tmdbId, language, TmdbUtils.GetImageLanguagesParam(language, searchInfo.MetadataCountryCode), searchInfo.MetadataCountryCode, cancellationToken).ConfigureAwait(false); if (collection is null) { @@ -70,7 +70,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets return new[] { result }; } - var collectionSearchResults = await _tmdbClientManager.SearchCollectionAsync(searchInfo.Name, language, cancellationToken).ConfigureAwait(false); + var collectionSearchResults = await _tmdbClientManager.SearchCollectionAsync(searchInfo.Name, language, searchInfo.MetadataCountryCode, cancellationToken).ConfigureAwait(false); var collections = new RemoteSearchResult[collectionSearchResults.Count]; for (var i = 0; i < collectionSearchResults.Count; i++) @@ -95,6 +95,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets { var tmdbId = Convert.ToInt32(info.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); var language = info.MetadataLanguage; + // We don't already have an Id, need to fetch it if (tmdbId <= 0) { @@ -102,7 +103,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets // Caller provides the filename with extension stripped and NOT the parsed filename var parsedName = _libraryManager.ParseName(info.Name); var cleanedName = TmdbUtils.CleanName(parsedName.Name); - var searchResults = await _tmdbClientManager.SearchCollectionAsync(cleanedName, language, cancellationToken).ConfigureAwait(false); + var searchResults = await _tmdbClientManager.SearchCollectionAsync(cleanedName, language, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); if (searchResults is not null && searchResults.Count > 0) { @@ -114,7 +115,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets if (tmdbId > 0) { - var collection = await _tmdbClientManager.GetCollectionAsync(tmdbId, language, TmdbUtils.GetImageLanguagesParam(language), cancellationToken).ConfigureAwait(false); + var collection = await _tmdbClientManager.GetCollectionAsync(tmdbId, language, TmdbUtils.GetImageLanguagesParam(language, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); if (collection is not null) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs index 1696a2c498..fcc3574107 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs @@ -59,6 +59,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) { var language = item.GetPreferredMetadataLanguage(); + var countryCode = item.GetPreferredMetadataCountryCode(); var movieTmdbId = Convert.ToInt32(item.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); if (movieTmdbId <= 0) @@ -69,7 +70,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies return Enumerable.Empty<RemoteImageInfo>(); } - var movieResult = await _tmdbClientManager.FindByExternalIdAsync(movieImdbId, FindExternalSource.Imdb, language, cancellationToken).ConfigureAwait(false); + var movieResult = await _tmdbClientManager.FindByExternalIdAsync(movieImdbId, FindExternalSource.Imdb, language, countryCode, cancellationToken).ConfigureAwait(false); if (movieResult?.MovieResults is not null && movieResult.MovieResults.Count > 0) { movieTmdbId = movieResult.MovieResults[0].Id; @@ -83,7 +84,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies // TODO use image languages if All Languages isn't toggled, but there's currently no way to get that value in here var movie = await _tmdbClientManager - .GetMovieAsync(movieTmdbId, null, null, cancellationToken) + .GetMovieAsync(movieTmdbId, null, null, null, cancellationToken) .ConfigureAwait(false); if (movie?.Images is null) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs index ab072be03f..414a0a3c9b 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs @@ -59,7 +59,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies .GetMovieAsync( int.Parse(id, CultureInfo.InvariantCulture), searchInfo.MetadataLanguage, - TmdbUtils.GetImageLanguagesParam(searchInfo.MetadataLanguage), + TmdbUtils.GetImageLanguagesParam(searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode), + searchInfo.MetadataCountryCode, cancellationToken) .ConfigureAwait(false); @@ -93,7 +94,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies var result = await _tmdbClientManager.FindByExternalIdAsync( id, FindExternalSource.Imdb, - TmdbUtils.GetImageLanguagesParam(searchInfo.MetadataLanguage), + TmdbUtils.GetImageLanguagesParam(searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode), + searchInfo.MetadataCountryCode, cancellationToken).ConfigureAwait(false); movieResults = result?.MovieResults; } @@ -103,7 +105,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies var result = await _tmdbClientManager.FindByExternalIdAsync( id, FindExternalSource.TvDb, - TmdbUtils.GetImageLanguagesParam(searchInfo.MetadataLanguage), + TmdbUtils.GetImageLanguagesParam(searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode), + searchInfo.MetadataCountryCode, cancellationToken).ConfigureAwait(false); movieResults = result?.MovieResults; } @@ -111,7 +114,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies if (movieResults is null) { movieResults = await _tmdbClientManager - .SearchMovieAsync(searchInfo.Name, searchInfo.Year ?? 0, searchInfo.MetadataLanguage, cancellationToken) + .SearchMovieAsync(searchInfo.Name, searchInfo.Year ?? 0, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken) .ConfigureAwait(false); } @@ -152,7 +155,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies // Caller provides the filename with extension stripped and NOT the parsed filename var parsedName = _libraryManager.ParseName(info.Name); var cleanedName = TmdbUtils.CleanName(parsedName.Name); - var searchResults = await _tmdbClientManager.SearchMovieAsync(cleanedName, info.Year ?? parsedName.Year ?? 0, info.MetadataLanguage, cancellationToken).ConfigureAwait(false); + + var searchResults = await _tmdbClientManager.SearchMovieAsync(cleanedName, info.Year ?? parsedName.Year ?? 0, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); if (searchResults.Count > 0) { @@ -162,7 +166,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies if (string.IsNullOrEmpty(tmdbId) && !string.IsNullOrEmpty(imdbId)) { - var movieResultFromImdbId = await _tmdbClientManager.FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, info.MetadataLanguage, cancellationToken).ConfigureAwait(false); + var movieResultFromImdbId = await _tmdbClientManager.FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); if (movieResultFromImdbId?.MovieResults.Count > 0) { tmdbId = movieResultFromImdbId.MovieResults[0].Id.ToString(CultureInfo.InvariantCulture); @@ -175,7 +179,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies } var movieResult = await _tmdbClientManager - .GetMovieAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage), cancellationToken) + .GetMovieAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken) .ConfigureAwait(false); if (movieResult is null) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs index 9e5404b325..33888ddf4f 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs @@ -60,7 +60,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People } var language = item.GetPreferredMetadataLanguage(); - var personResult = await _tmdbClientManager.GetPersonAsync(int.Parse(personTmdbId, CultureInfo.InvariantCulture), language, cancellationToken).ConfigureAwait(false); + var countryCode = item.GetPreferredMetadataCountryCode(); + var personResult = await _tmdbClientManager.GetPersonAsync(int.Parse(personTmdbId, CultureInfo.InvariantCulture), language, countryCode, cancellationToken).ConfigureAwait(false); if (personResult?.Images?.Profiles is null) { return Enumerable.Empty<RemoteImageInfo>(); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs index 98c46895d7..4b32d0f6bf 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs @@ -39,7 +39,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People { if (searchInfo.TryGetProviderId(MetadataProvider.Tmdb, out var personTmdbId)) { - var personResult = await _tmdbClientManager.GetPersonAsync(int.Parse(personTmdbId, CultureInfo.InvariantCulture), searchInfo.MetadataLanguage, cancellationToken).ConfigureAwait(false); + var personResult = await _tmdbClientManager.GetPersonAsync(int.Parse(personTmdbId, CultureInfo.InvariantCulture), searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken).ConfigureAwait(false); if (personResult is not null) { @@ -101,7 +101,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People if (personTmdbId > 0) { - var person = await _tmdbClientManager.GetPersonAsync(personTmdbId, info.MetadataLanguage, cancellationToken).ConfigureAwait(false); + var person = await _tmdbClientManager.GetPersonAsync(personTmdbId, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); if (person is null) { return result; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs index 7de0e430f2..7ae54cdcd3 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs @@ -75,7 +75,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV // TODO use image languages if All Languages isn't toggled, but there's currently no way to get that value in here var episodeResult = await _tmdbClientManager - .GetEpisodeAsync(seriesTmdbId, seasonNumber, episodeNumber.Value, series.DisplayOrder, null, null, cancellationToken) + .GetEpisodeAsync(seriesTmdbId, seasonNumber, episodeNumber.Value, series.DisplayOrder, null, null, null, cancellationToken) .ConfigureAwait(false); var stills = episodeResult?.Images?.Stills; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs index 7d0900cfda..e30c555cb4 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs @@ -113,7 +113,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV List<TvEpisode>? result = null; for (int? episode = startindex; episode <= endindex; episode++) { - var episodeInfo = await _tmdbClientManager.GetEpisodeAsync(seriesTmdbId, seasonNumber, episode.Value, info.SeriesDisplayOrder, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage), cancellationToken).ConfigureAwait(false); + var episodeInfo = await _tmdbClientManager.GetEpisodeAsync(seriesTmdbId, seasonNumber, episode.Value, info.SeriesDisplayOrder, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); if (episodeInfo is not null) { (result ??= new List<TvEpisode>()).Add(episodeInfo); @@ -157,7 +157,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV else { episodeResult = await _tmdbClientManager - .GetEpisodeAsync(seriesTmdbId, seasonNumber, episodeNumber.Value, info.SeriesDisplayOrder, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage), cancellationToken) + .GetEpisodeAsync(seriesTmdbId, seasonNumber, episodeNumber.Value, info.SeriesDisplayOrder, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken) .ConfigureAwait(false); } @@ -177,8 +177,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV var item = new Episode { - IndexNumber = info.IndexNumber, - ParentIndexNumber = info.ParentIndexNumber, + IndexNumber = episodeNumber, + ParentIndexNumber = seasonNumber, IndexNumberEnd = info.IndexNumberEnd, Name = episodeResult.Name, PremiereDate = episodeResult.AirDate, diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs index a743601ed3..5b2f0d26e4 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs @@ -68,7 +68,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV // TODO use image languages if All Languages isn't toggled, but there's currently no way to get that value in here var seasonResult = await _tmdbClientManager - .GetSeasonAsync(seriesTmdbId, season.IndexNumber.Value, null, null, cancellationToken) + .GetSeasonAsync(seriesTmdbId, season.IndexNumber.Value, null, null, null, cancellationToken) .ConfigureAwait(false); var posters = seasonResult?.Images?.Posters; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs index cfef0d6561..1b429039e7 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs @@ -54,7 +54,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV } var seasonResult = await _tmdbClientManager - .GetSeasonAsync(Convert.ToInt32(seriesTmdbId, CultureInfo.InvariantCulture), seasonNumber.Value, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage), cancellationToken) + .GetSeasonAsync(Convert.ToInt32(seriesTmdbId, CultureInfo.InvariantCulture), seasonNumber.Value, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken) .ConfigureAwait(false); if (seasonResult is null) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs index 2cb4fe1c15..5cba84dcb3 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs @@ -68,7 +68,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV // TODO use image languages if All Languages isn't toggled, but there's currently no way to get that value in here var series = await _tmdbClientManager - .GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), null, null, cancellationToken) + .GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), null, null, null, cancellationToken) .ConfigureAwait(false); if (series?.Images is null) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs index 8791712c71..f0828e8263 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs @@ -57,7 +57,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV if (searchInfo.TryGetProviderId(MetadataProvider.Tmdb, out var tmdbId)) { var series = await _tmdbClientManager - .GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), searchInfo.MetadataLanguage, searchInfo.MetadataLanguage, cancellationToken) + .GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), searchInfo.MetadataLanguage, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken) .ConfigureAwait(false); if (series is not null) @@ -71,7 +71,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV if (searchInfo.TryGetProviderId(MetadataProvider.Imdb, out var imdbId)) { var findResult = await _tmdbClientManager - .FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, searchInfo.MetadataLanguage, cancellationToken) + .FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken) .ConfigureAwait(false); var tvResults = findResult?.TvResults; @@ -92,7 +92,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV if (searchInfo.TryGetProviderId(MetadataProvider.Tvdb, out var tvdbId)) { var findResult = await _tmdbClientManager - .FindByExternalIdAsync(tvdbId, FindExternalSource.TvDb, searchInfo.MetadataLanguage, cancellationToken) + .FindByExternalIdAsync(tvdbId, FindExternalSource.TvDb, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken) .ConfigureAwait(false); var tvResults = findResult?.TvResults; @@ -110,7 +110,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV } } - var tvSearchResults = await _tmdbClientManager.SearchSeriesAsync(searchInfo.Name, searchInfo.MetadataLanguage, cancellationToken: cancellationToken) + var tvSearchResults = await _tmdbClientManager.SearchSeriesAsync(searchInfo.Name, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken: cancellationToken) .ConfigureAwait(false); var remoteResults = new RemoteSearchResult[tvSearchResults.Count]; @@ -173,7 +173,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV if (string.IsNullOrEmpty(tmdbId) && info.TryGetProviderId(MetadataProvider.Imdb, out var imdbId)) { - var searchResult = await _tmdbClientManager.FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, info.MetadataLanguage, cancellationToken).ConfigureAwait(false); + var searchResult = await _tmdbClientManager.FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); if (searchResult?.TvResults.Count > 0) { tmdbId = searchResult.TvResults[0].Id.ToString(CultureInfo.InvariantCulture); @@ -182,7 +182,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV if (string.IsNullOrEmpty(tmdbId) && info.TryGetProviderId(MetadataProvider.Tvdb, out var tvdbId)) { - var searchResult = await _tmdbClientManager.FindByExternalIdAsync(tvdbId, FindExternalSource.TvDb, info.MetadataLanguage, cancellationToken).ConfigureAwait(false); + var searchResult = await _tmdbClientManager.FindByExternalIdAsync(tvdbId, FindExternalSource.TvDb, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); if (searchResult?.TvResults.Count > 0) { tmdbId = searchResult.TvResults[0].Id.ToString(CultureInfo.InvariantCulture); @@ -196,7 +196,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV // Caller provides the filename with extension stripped and NOT the parsed filename var parsedName = _libraryManager.ParseName(info.Name); var cleanedName = TmdbUtils.CleanName(parsedName.Name); - var searchResults = await _tmdbClientManager.SearchSeriesAsync(cleanedName, info.MetadataLanguage, info.Year ?? parsedName.Year ?? 0, cancellationToken).ConfigureAwait(false); + var searchResults = await _tmdbClientManager.SearchSeriesAsync(cleanedName, info.MetadataLanguage, info.MetadataCountryCode, info.Year ?? parsedName.Year ?? 0, cancellationToken).ConfigureAwait(false); if (searchResults.Count > 0) { @@ -212,7 +212,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV cancellationToken.ThrowIfCancellationRequested(); var tvShow = await _tmdbClientManager - .GetSeriesAsync(tmdbIdInt, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage), cancellationToken) + .GetSeriesAsync(tmdbIdInt, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken) .ConfigureAwait(false); if (tvShow is null) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs index 767004c9e5..fedf345988 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs @@ -51,9 +51,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="tmdbId">The movie's TMDb id.</param> /// <param name="language">The movie's language.</param> /// <param name="imageLanguages">A comma-separated list of image languages.</param> + /// <param name="countryCode">The country code, ISO 3166-1.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb movie or null if not found.</returns> - public async Task<Movie?> GetMovieAsync(int tmdbId, string? language, string? imageLanguages, CancellationToken cancellationToken) + public async Task<Movie?> GetMovieAsync(int tmdbId, string? language, string? imageLanguages, string? countryCode, CancellationToken cancellationToken) { var key = $"movie-{tmdbId.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out Movie? movie)) @@ -71,7 +72,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb movie = await _tmDbClient.GetMovieAsync( tmdbId, - TmdbUtils.NormalizeLanguage(language), + TmdbUtils.NormalizeLanguage(language, countryCode), imageLanguages, extraMethods, cancellationToken).ConfigureAwait(false); @@ -90,9 +91,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="tmdbId">The collection's TMDb id.</param> /// <param name="language">The collection's language.</param> /// <param name="imageLanguages">A comma-separated list of image languages.</param> + /// <param name="countryCode">The country code, ISO 3166-1.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb collection or null if not found.</returns> - public async Task<Collection?> GetCollectionAsync(int tmdbId, string? language, string? imageLanguages, CancellationToken cancellationToken) + public async Task<Collection?> GetCollectionAsync(int tmdbId, string? language, string? imageLanguages, string? countryCode, CancellationToken cancellationToken) { var key = $"collection-{tmdbId.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out Collection? collection)) @@ -104,7 +106,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb collection = await _tmDbClient.GetCollectionAsync( tmdbId, - TmdbUtils.NormalizeLanguage(language), + TmdbUtils.NormalizeLanguage(language, countryCode), imageLanguages, CollectionMethods.Images, cancellationToken).ConfigureAwait(false); @@ -123,9 +125,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="tmdbId">The tv show's TMDb id.</param> /// <param name="language">The tv show's language.</param> /// <param name="imageLanguages">A comma-separated list of image languages.</param> + /// <param name="countryCode">The country code, ISO 3166-1.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb tv show information or null if not found.</returns> - public async Task<TvShow?> GetSeriesAsync(int tmdbId, string? language, string? imageLanguages, CancellationToken cancellationToken) + public async Task<TvShow?> GetSeriesAsync(int tmdbId, string? language, string? imageLanguages, string? countryCode, CancellationToken cancellationToken) { var key = $"series-{tmdbId.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out TvShow? series)) @@ -143,7 +146,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb series = await _tmDbClient.GetTvShowAsync( tmdbId, - language: TmdbUtils.NormalizeLanguage(language), + language: TmdbUtils.NormalizeLanguage(language, countryCode), includeImageLanguage: imageLanguages, extraMethods: extraMethods, cancellationToken: cancellationToken).ConfigureAwait(false); @@ -163,9 +166,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="displayOrder">The display order.</param> /// <param name="language">The tv show's language.</param> /// <param name="imageLanguages">A comma-separated list of image languages.</param> + /// <param name="countryCode">The country code, ISO 3166-1.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb tv show episode group information or null if not found.</returns> - private async Task<TvGroupCollection?> GetSeriesGroupAsync(int tvShowId, string displayOrder, string? language, string? imageLanguages, CancellationToken cancellationToken) + private async Task<TvGroupCollection?> GetSeriesGroupAsync(int tvShowId, string displayOrder, string? language, string? imageLanguages, string? countryCode, CancellationToken cancellationToken) { TvGroupType? groupType = string.Equals(displayOrder, "originalAirDate", StringComparison.Ordinal) ? TvGroupType.OriginalAirDate : @@ -190,7 +194,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb await EnsureClientConfigAsync().ConfigureAwait(false); - var series = await GetSeriesAsync(tvShowId, language, imageLanguages, cancellationToken).ConfigureAwait(false); + var series = await GetSeriesAsync(tvShowId, language, imageLanguages, countryCode, cancellationToken).ConfigureAwait(false); var episodeGroupId = series?.EpisodeGroups.Results.Find(g => g.Type == groupType)?.Id; if (episodeGroupId is null) @@ -200,7 +204,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb group = await _tmDbClient.GetTvEpisodeGroupsAsync( episodeGroupId, - language: TmdbUtils.NormalizeLanguage(language), + language: TmdbUtils.NormalizeLanguage(language, countryCode), cancellationToken: cancellationToken).ConfigureAwait(false); if (group is not null) @@ -218,9 +222,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="seasonNumber">The season number.</param> /// <param name="language">The tv season's language.</param> /// <param name="imageLanguages">A comma-separated list of image languages.</param> + /// <param name="countryCode">The country code, ISO 3166-1.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb tv season information or null if not found.</returns> - public async Task<TvSeason?> GetSeasonAsync(int tvShowId, int seasonNumber, string? language, string? imageLanguages, CancellationToken cancellationToken) + public async Task<TvSeason?> GetSeasonAsync(int tvShowId, int seasonNumber, string? language, string? imageLanguages, string? countryCode, CancellationToken cancellationToken) { var key = $"season-{tvShowId.ToString(CultureInfo.InvariantCulture)}-s{seasonNumber.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out TvSeason? season)) @@ -233,7 +238,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb season = await _tmDbClient.GetTvSeasonAsync( tvShowId, seasonNumber, - language: TmdbUtils.NormalizeLanguage(language), + language: TmdbUtils.NormalizeLanguage(language, countryCode), includeImageLanguage: imageLanguages, extraMethods: TvSeasonMethods.Credits | TvSeasonMethods.Images | TvSeasonMethods.ExternalIds | TvSeasonMethods.Videos, cancellationToken: cancellationToken).ConfigureAwait(false); @@ -255,9 +260,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="displayOrder">The display order.</param> /// <param name="language">The episode's language.</param> /// <param name="imageLanguages">A comma-separated list of image languages.</param> + /// <param name="countryCode">The country code, ISO 3166-1.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb tv episode information or null if not found.</returns> - public async Task<TvEpisode?> GetEpisodeAsync(int tvShowId, int seasonNumber, int episodeNumber, string displayOrder, string? language, string? imageLanguages, CancellationToken cancellationToken) + public async Task<TvEpisode?> GetEpisodeAsync(int tvShowId, int seasonNumber, int episodeNumber, string displayOrder, string? language, string? imageLanguages, string? countryCode, CancellationToken cancellationToken) { var key = $"episode-{tvShowId.ToString(CultureInfo.InvariantCulture)}-s{seasonNumber.ToString(CultureInfo.InvariantCulture)}e{episodeNumber.ToString(CultureInfo.InvariantCulture)}-{displayOrder}-{language}"; if (_memoryCache.TryGetValue(key, out TvEpisode? episode)) @@ -267,7 +273,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb await EnsureClientConfigAsync().ConfigureAwait(false); - var group = await GetSeriesGroupAsync(tvShowId, displayOrder, language, imageLanguages, cancellationToken).ConfigureAwait(false); + var group = await GetSeriesGroupAsync(tvShowId, displayOrder, language, imageLanguages, countryCode, cancellationToken).ConfigureAwait(false); if (group is not null) { var season = group.Groups.Find(s => s.Order == seasonNumber); @@ -284,7 +290,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb tvShowId, seasonNumber, episodeNumber, - language: TmdbUtils.NormalizeLanguage(language), + language: TmdbUtils.NormalizeLanguage(language, countryCode), includeImageLanguage: imageLanguages, extraMethods: TvEpisodeMethods.Credits | TvEpisodeMethods.Images | TvEpisodeMethods.ExternalIds | TvEpisodeMethods.Videos, cancellationToken: cancellationToken).ConfigureAwait(false); @@ -301,10 +307,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// Gets a person eg. cast or crew member from the TMDb API based on its TMDb id. /// </summary> /// <param name="personTmdbId">The person's TMDb id.</param> - /// <param name="language">The episode's language.</param> + /// <param name="language">The person's language.</param> + /// <param name="countryCode">The country code, ISO 3166-1.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb person information or null if not found.</returns> - public async Task<Person?> GetPersonAsync(int personTmdbId, string language, CancellationToken cancellationToken) + public async Task<Person?> GetPersonAsync(int personTmdbId, string language, string? countryCode, CancellationToken cancellationToken) { var key = $"person-{personTmdbId.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out Person? person)) @@ -316,7 +323,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb person = await _tmDbClient.GetPersonAsync( personTmdbId, - TmdbUtils.NormalizeLanguage(language), + TmdbUtils.NormalizeLanguage(language, countryCode), PersonMethods.TvCredits | PersonMethods.MovieCredits | PersonMethods.Images | PersonMethods.ExternalIds, cancellationToken).ConfigureAwait(false); @@ -334,12 +341,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="externalId">The item's external id.</param> /// <param name="source">The source of the id eg. IMDb.</param> /// <param name="language">The item's language.</param> + /// <param name="countryCode">The country code, ISO 3166-1.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb item or null if not found.</returns> public async Task<FindContainer?> FindByExternalIdAsync( string externalId, FindExternalSource source, string language, + string? countryCode, CancellationToken cancellationToken) { var key = $"find-{source.ToString()}-{externalId.ToString(CultureInfo.InvariantCulture)}-{language}"; @@ -353,7 +362,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb result = await _tmDbClient.FindAsync( source, externalId, - TmdbUtils.NormalizeLanguage(language), + TmdbUtils.NormalizeLanguage(language, countryCode), cancellationToken).ConfigureAwait(false); if (result is not null) @@ -369,10 +378,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// </summary> /// <param name="name">The name of the tv show.</param> /// <param name="language">The tv show's language.</param> + /// <param name="countryCode">The country code, ISO 3166-1.</param> /// <param name="year">The year the tv show first aired.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb tv show information.</returns> - public async Task<IReadOnlyList<SearchTv>> SearchSeriesAsync(string name, string language, int year = 0, CancellationToken cancellationToken = default) + public async Task<IReadOnlyList<SearchTv>> SearchSeriesAsync(string name, string language, string? countryCode, int year = 0, CancellationToken cancellationToken = default) { var key = $"searchseries-{name}-{year.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out SearchContainer<SearchTv>? series) && series is not null) @@ -383,7 +393,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb await EnsureClientConfigAsync().ConfigureAwait(false); var searchResults = await _tmDbClient - .SearchTvShowAsync(name, TmdbUtils.NormalizeLanguage(language), includeAdult: Plugin.Instance.Configuration.IncludeAdult, firstAirDateYear: year, cancellationToken: cancellationToken) + .SearchTvShowAsync(name, TmdbUtils.NormalizeLanguage(language, countryCode), includeAdult: Plugin.Instance.Configuration.IncludeAdult, firstAirDateYear: year, cancellationToken: cancellationToken) .ConfigureAwait(false); if (searchResults.Results.Count > 0) @@ -431,7 +441,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <returns>The TMDb movie information.</returns> public Task<IReadOnlyList<SearchMovie>> SearchMovieAsync(string name, string language, CancellationToken cancellationToken) { - return SearchMovieAsync(name, 0, language, cancellationToken); + return SearchMovieAsync(name, 0, language, null, cancellationToken); } /// <summary> @@ -440,9 +450,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="name">The name of the movie.</param> /// <param name="year">The release year of the movie.</param> /// <param name="language">The movie's language.</param> + /// <param name="countryCode">The country code, ISO 3166-1.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb movie information.</returns> - public async Task<IReadOnlyList<SearchMovie>> SearchMovieAsync(string name, int year, string language, CancellationToken cancellationToken) + public async Task<IReadOnlyList<SearchMovie>> SearchMovieAsync(string name, int year, string language, string? countryCode, CancellationToken cancellationToken) { var key = $"moviesearch-{name}-{year.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out SearchContainer<SearchMovie>? movies) && movies is not null) @@ -453,7 +464,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb await EnsureClientConfigAsync().ConfigureAwait(false); var searchResults = await _tmDbClient - .SearchMovieAsync(name, TmdbUtils.NormalizeLanguage(language), includeAdult: Plugin.Instance.Configuration.IncludeAdult, year: year, cancellationToken: cancellationToken) + .SearchMovieAsync(name, TmdbUtils.NormalizeLanguage(language, countryCode), includeAdult: Plugin.Instance.Configuration.IncludeAdult, year: year, cancellationToken: cancellationToken) .ConfigureAwait(false); if (searchResults.Results.Count > 0) @@ -469,9 +480,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// </summary> /// <param name="name">The name of the collection.</param> /// <param name="language">The collection's language.</param> + /// <param name="countryCode">The country code, ISO 3166-1.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb collection information.</returns> - public async Task<IReadOnlyList<SearchCollection>> SearchCollectionAsync(string name, string language, CancellationToken cancellationToken) + public async Task<IReadOnlyList<SearchCollection>> SearchCollectionAsync(string name, string language, string? countryCode, CancellationToken cancellationToken) { var key = $"collectionsearch-{name}-{language}"; if (_memoryCache.TryGetValue(key, out SearchContainer<SearchCollection>? collections) && collections is not null) @@ -482,7 +494,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb await EnsureClientConfigAsync().ConfigureAwait(false); var searchResults = await _tmDbClient - .SearchCollectionAsync(name, TmdbUtils.NormalizeLanguage(language), cancellationToken: cancellationToken) + .SearchCollectionAsync(name, TmdbUtils.NormalizeLanguage(language, countryCode), cancellationToken: cancellationToken) .ConfigureAwait(false); if (searchResults.Results.Count > 0) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs index 2db8cae7e5..f5e59a2789 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs @@ -105,14 +105,15 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// Normalizes a language string for use with TMDb's include image language parameter. /// </summary> /// <param name="preferredLanguage">The preferred language as either a 2 letter code with or without country code.</param> + /// <param name="countryCode">The country code, ISO 3166-1.</param> /// <returns>The comma separated language string.</returns> - public static string GetImageLanguagesParam(string preferredLanguage) + public static string GetImageLanguagesParam(string preferredLanguage, string? countryCode = null) { var languages = new List<string>(); if (!string.IsNullOrEmpty(preferredLanguage)) { - preferredLanguage = NormalizeLanguage(preferredLanguage); + preferredLanguage = NormalizeLanguage(preferredLanguage, countryCode); languages.Add(preferredLanguage); @@ -140,15 +141,24 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// Normalizes a language string for use with TMDb's language parameter. /// </summary> /// <param name="language">The language code.</param> + /// <param name="countryCode">The country code.</param> /// <returns>The normalized language code.</returns> [return: NotNullIfNotNull(nameof(language))] - public static string? NormalizeLanguage(string? language) + public static string? NormalizeLanguage(string? language, string? countryCode = null) { if (string.IsNullOrEmpty(language)) { return language; } + // Handle es-419 (Latin American Spanish) by converting to regional variant + if (string.Equals(language, "es-419", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(countryCode)) + { + language = string.Equals(countryCode, "AR", StringComparison.OrdinalIgnoreCase) + ? "es-AR" + : "es-MX"; + } + // TMDb requires this to be uppercase // Everything after the hyphen must be written in uppercase due to a way TMDb wrote their API. // See here: https://www.themoviedb.org/talk/5119221d760ee36c642af4ad?page=3#56e372a0c3a3685a9e0019ab diff --git a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs index 75ad0d58ca..3f83f1d829 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs @@ -107,6 +107,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers // Additional Mappings _validProviderIds.Add("collectionnumber", "TmdbCollection"); _validProviderIds.Add("tmdbcolid", "TmdbCollection"); + _validProviderIds.Add("tmdbcol", "TmdbCollection"); _validProviderIds.Add("imdb_id", "Imdb"); Fetch(item, metadataFile, GetXmlReaderSettings(), cancellationToken); @@ -315,7 +316,11 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (userData is not null) { userData.Played = played; - _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None); + + if (!item.Id.IsEmpty()) + { + _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None); + } } } } @@ -332,7 +337,11 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (userData is not null) { userData.PlayCount = count; - _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None); + + if (!item.Id.IsEmpty()) + { + _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None); + } } } } @@ -349,7 +358,11 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (userData is not null) { userData.LastPlayedDate = lastPlayed; - _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None); + + if (!item.Id.IsEmpty()) + { + _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None); + } } } } @@ -590,7 +603,18 @@ namespace MediaBrowser.XbmcMetadata.Parsers var provider = reader.GetAttribute("type"); var providerId = reader.ReadElementContentAsString(); - item.TrySetProviderId(provider, providerId); + + if (!string.IsNullOrEmpty(provider)) + { + if (_validProviderIds.TryGetValue(provider, out string? normalizedProvider)) + { + item.TrySetProviderId(normalizedProvider, providerId); + } + else + { + item.TrySetProviderId(provider, providerId); + } + } break; case "thumb": diff --git a/SharedVersion.cs b/SharedVersion.cs index d26eb31aec..3b394d28b2 100644 --- a/SharedVersion.cs +++ b/SharedVersion.cs @@ -1,4 +1,4 @@ using System.Reflection; -[assembly: AssemblyVersion("10.11.0")] -[assembly: AssemblyFileVersion("10.11.0")] +[assembly: AssemblyVersion("10.12.0")] +[assembly: AssemblyFileVersion("10.12.0")] diff --git a/bump_version b/bump_version index 6d08dc72fe..0516a1806d 100755 --- a/bump_version +++ b/bump_version @@ -58,7 +58,7 @@ for subproject in ${jellyfin_subprojects[@]}; do done # Set the version in the GitHub issue template file -sed -i "s|${old_version}|${new_version_sed}|g" ${issue_template_file} +sed -i "s|${old_version}|${new_version_sed}|g" "${issue_template_file}" # Stage the changed files for commit git add . diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs index 4d5cfb8c9b..f386e882e2 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs @@ -54,6 +54,34 @@ public static class JellyfinQueryHelperExtensions } /// <summary> + /// Builds a query that checks referenced ItemValues for a cross BaseItem lookup. + /// </summary> + /// <param name="baseQuery">The source query.</param> + /// <param name="context">The database context.</param> + /// <param name="itemValueTypes">The type of item value to reference.</param> + /// <param name="referenceIds">The list of BaseItem ids to check matches.</param> + /// <param name="invert">If set an exclusion check is performed instead.</param> + /// <returns>A Query.</returns> + public static IQueryable<BaseItemEntity> WhereReferencedItemMultipleTypes( + this IQueryable<BaseItemEntity> baseQuery, + JellyfinDbContext context, + IList<ItemValueType> itemValueTypes, + IList<Guid> referenceIds, + bool invert = false) + { + var itemFilter = OneOrManyExpressionBuilder<BaseItemEntity, Guid>(referenceIds, f => f.Id); + var typeFilter = OneOrManyExpressionBuilder<ItemValue, ItemValueType>(itemValueTypes, iv => iv.Type); + + return baseQuery.Where(item => + context.ItemValues + .Where(typeFilter) + .Join(context.ItemValuesMap, e => e.ItemValueId, e => e.ItemValueId, (itemVal, map) => new { itemVal, map }) + .Any(val => + context.BaseItems.Where(itemFilter).Any(e => e.CleanName == val.itemVal.CleanValue) + && val.map.ItemId == item.Id) == EF.Constant(!invert)); + } + + /// <summary> /// Builds a query expression that checks referenced ItemValues for a cross BaseItem lookup. /// </summary> /// <param name="context">The database context.</param> diff --git a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj index 1613d83bc3..f52fd014da 100644 --- a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj +++ b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj @@ -15,7 +15,7 @@ <PropertyGroup> <Authors>Jellyfin Contributors</Authors> <PackageId>Jellyfin.Extensions</PackageId> - <VersionPrefix>10.11.0</VersionPrefix> + <VersionPrefix>10.12.0</VersionPrefix> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> </PropertyGroup> diff --git a/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs b/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs index a0dafb8f19..cbe97a8210 100644 --- a/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs +++ b/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs @@ -42,7 +42,15 @@ public static class FfProbeKeyframeExtractor try { process.Start(); - process.PriorityClass = ProcessPriorityClass.BelowNormal; + try + { + process.PriorityClass = ProcessPriorityClass.BelowNormal; + } + catch + { + // We do not care if process priority setting fails + // Ideally log a warning but this does not have a logger available + } return ParseStream(process.StandardOutput); } diff --git a/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs b/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs index 4c8ba58d04..7671166ff4 100644 --- a/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs @@ -7,23 +7,38 @@ public class SeasonPathParserTests { [Theory] [InlineData("/Drive/Season 1", "/Drive", 1, true)] + [InlineData("/Drive/SEASON 1", "/Drive", 1, true)] [InlineData("/Drive/Staffel 1", "/Drive", 1, true)] + [InlineData("/Drive/STAFFEL 1", "/Drive", 1, true)] [InlineData("/Drive/Stagione 1", "/Drive", 1, true)] + [InlineData("/Drive/STAGIONE 1", "/Drive", 1, true)] [InlineData("/Drive/sæson 1", "/Drive", 1, true)] + [InlineData("/Drive/SÆSON 1", "/Drive", 1, true)] [InlineData("/Drive/Temporada 1", "/Drive", 1, true)] + [InlineData("/Drive/TEMPORADA 1", "/Drive", 1, true)] [InlineData("/Drive/series 1", "/Drive", 1, true)] + [InlineData("/Drive/SERIES 1", "/Drive", 1, true)] [InlineData("/Drive/Kausi 1", "/Drive", 1, true)] + [InlineData("/Drive/KAUSI 1", "/Drive", 1, true)] [InlineData("/Drive/Säsong 1", "/Drive", 1, true)] + [InlineData("/Drive/SÄSONG 1", "/Drive", 1, true)] [InlineData("/Drive/Seizoen 1", "/Drive", 1, true)] + [InlineData("/Drive/SEIZOEN 1", "/Drive", 1, true)] [InlineData("/Drive/Seasong 1", "/Drive", 1, true)] + [InlineData("/Drive/SEASONG 1", "/Drive", 1, true)] [InlineData("/Drive/Sezon 1", "/Drive", 1, true)] + [InlineData("/Drive/SEZON 1", "/Drive", 1, true)] [InlineData("/Drive/sezona 1", "/Drive", 1, true)] + [InlineData("/Drive/SEZONA 1", "/Drive", 1, true)] [InlineData("/Drive/sezóna 1", "/Drive", 1, true)] + [InlineData("/Drive/SEZÓNA 1", "/Drive", 1, true)] [InlineData("/Drive/Sezonul 1", "/Drive", 1, true)] + [InlineData("/Drive/SEZONUL 1", "/Drive", 1, true)] [InlineData("/Drive/시즌 1", "/Drive", 1, true)] [InlineData("/Drive/シーズン 1", "/Drive", 1, true)] [InlineData("/Drive/сезон 1", "/Drive", 1, true)] [InlineData("/Drive/Сезон 1", "/Drive", 1, true)] + [InlineData("/Drive/СЕЗОН 1", "/Drive", 1, true)] [InlineData("/Drive/Season 10", "/Drive", 10, true)] [InlineData("/Drive/Season 100", "/Drive", 100, true)] [InlineData("/Drive/s1", "/Drive", 1, true)] @@ -46,8 +61,11 @@ public class SeasonPathParserTests [InlineData("/Drive/s06e05", "/Drive", null, false)] [InlineData("/Drive/The.Legend.of.Condor.Heroes.2017.V2.web-dl.1080p.h264.aac-hdctv", "/Drive", null, false)] [InlineData("/Drive/extras", "/Drive", 0, true)] + [InlineData("/Drive/EXTRAS", "/Drive", 0, true)] [InlineData("/Drive/specials", "/Drive", 0, true)] + [InlineData("/Drive/SPECIALS", "/Drive", 0, true)] [InlineData("/Drive/Episode 1 Season 2", "/Drive", null, false)] + [InlineData("/Drive/Episode 1 SEASON 2", "/Drive", null, false)] public void GetSeasonNumberFromPathTest(string path, string? parentPath, int? seasonNumber, bool isSeasonDirectory) { var result = SeasonPathParser.Parse(path, parentPath, true, true); diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs index e422eb9b8b..1e8652f4b9 100644 --- a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs +++ b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs @@ -275,5 +275,24 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers Assert.StartsWith(">>", item.Overview, StringComparison.InvariantCulture); Assert.EndsWith("<<", item.Overview, StringComparison.InvariantCulture); } + + [Fact] + public void Parse_TmdbcolUniqueId_NormalizedToTmdbCollection() + { + var result = new MetadataResult<Video>() + { + Item = new Movie() + }; + + _parser.Fetch(result, "Test Data/Lilo & Stitch.nfo", CancellationToken.None); + var item = (Movie)result.Item; + + // Verify that <uniqueid type="tmdbcol"> is normalized to TmdbCollection + Assert.True(item.ProviderIds.ContainsKey(MetadataProvider.TmdbCollection.ToString())); + Assert.Equal("97020", item.ProviderIds[MetadataProvider.TmdbCollection.ToString()]); + + // Verify that the lowercase "tmdbcol" is NOT in the provider IDs + Assert.False(item.ProviderIds.ContainsKey("tmdbcol")); + } } } diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Test Data/Lilo & Stitch.nfo b/tests/Jellyfin.XbmcMetadata.Tests/Test Data/Lilo & Stitch.nfo index 1eab687a2d..ca0be5dcca 100644 --- a/tests/Jellyfin.XbmcMetadata.Tests/Test Data/Lilo & Stitch.nfo +++ b/tests/Jellyfin.XbmcMetadata.Tests/Test Data/Lilo & Stitch.nfo @@ -2,6 +2,7 @@ <movie> <title>Lilo & Stitch</title> <originaltitle>Lilo & Stitch</originaltitle> + <uniqueid type="tmdbcol" default="false">97020</uniqueid> <set>Lilo & Stitch Collection</set> <plot>>>As Stitch, a runaway genetic experiment from a faraway planet, wreaks havoc on the Hawaiian Islands, he becomes the mischievous adopted alien "puppy" of an independent little girl named Lilo and learns about loyalty, friendship, and ʻohana, the Hawaiian tradition of family.<<</plot> </movie> |
