diff options
75 files changed, 996 insertions, 683 deletions
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index df2b50e26..029a48f6a 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "9.0.10", + "version": "9.0.11", "commands": [ "dotnet-ef" ] diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e1900d583..ef81678dd 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/ISSUE_TEMPLATE/issue report.yml b/.github/ISSUE_TEMPLATE/issue report.yml index 269968839..2ed8b0512 100644 --- a/.github/ISSUE_TEMPLATE/issue report.yml +++ b/.github/ISSUE_TEMPLATE/issue report.yml @@ -87,7 +87,10 @@ body: label: Jellyfin Server version description: What version of Jellyfin are you using? options: - - 10.10.0+ + - 10.11.3 + - 10.11.2 + - 10.11.1 + - 10.11.0 - Master - Unstable - Older* diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index 53daf0991..dba18f088 100644 --- a/.github/workflows/ci-codeql-analysis.yml +++ b/.github/workflows/ci-codeql-analysis.yml @@ -20,18 +20,18 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Setup .NET uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 with: dotnet-version: '9.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0 + uses: github/codeql-action/init@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0 + uses: github/codeql-action/autobuild@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0 + uses: github/codeql-action/analyze@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5 diff --git a/.github/workflows/ci-compat.yml b/.github/workflows/ci-compat.yml index 065123338..d483e048f 100644 --- a/.github/workflows/ci-compat.yml +++ b/.github/workflows/ci-compat.yml @@ -11,7 +11,7 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} @@ -40,7 +40,7 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} diff --git a/.github/workflows/ci-openapi.yml b/.github/workflows/ci-openapi.yml index d37602690..a957357f3 100644 --- a/.github/workflows/ci-openapi.yml +++ b/.github/workflows/ci-openapi.yml @@ -16,7 +16,7 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} @@ -41,7 +41,7 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} @@ -172,7 +172,7 @@ jobs: strip_components: 1 target: "/srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}" - name: Move openapi.json (unstable) into place - uses: appleboy/ssh-action@2ead5e36573f08b82fbfce1504f1a4b05a647c6f # v1.2.2 + uses: appleboy/ssh-action@91f3272fc5907f4699dcf59761eb622a07342f5a # v1.2.3 with: host: "${{ secrets.REPO_HOST }}" username: "${{ secrets.REPO_USER }}" @@ -234,7 +234,7 @@ jobs: strip_components: 1 target: "/srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}" - name: Move openapi.json (stable) into place - uses: appleboy/ssh-action@2ead5e36573f08b82fbfce1504f1a4b05a647c6f # v1.2.2 + uses: appleboy/ssh-action@91f3272fc5907f4699dcf59761eb622a07342f5a # v1.2.3 with: host: "${{ secrets.REPO_HOST }}" username: "${{ secrets.REPO_USER }}" diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index b9fdd456f..d2d649f16 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -20,7 +20,7 @@ jobs: runs-on: "${{ matrix.os }}" steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 with: @@ -35,7 +35,7 @@ jobs: --verbosity minimal - name: Merge code coverage results - uses: danielpalme/ReportGenerator-GitHub-Action@f3c6b3f8a29686284ef7a7cf6dccb79a01d98444 # v5.4.18 + uses: danielpalme/ReportGenerator-GitHub-Action@dcdfb6e704e87df6b2ed0cf123a6c9f69e364869 # v5.5.0 with: reports: "**/coverage.cobertura.xml" targetdir: "merged/" diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml index ba12d4747..729bcf69b 100644 --- a/.github/workflows/commands.yml +++ b/.github/workflows/commands.yml @@ -24,7 +24,7 @@ jobs: reactions: '+1' - name: Checkout the latest code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: token: ${{ secrets.JF_BOT_TOKEN }} fetch-depth: 0 @@ -40,7 +40,7 @@ jobs: runs-on: ubuntu-latest steps: - name: pull in script - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: repository: jellyfin/jellyfin-triage-script - name: install python diff --git a/.github/workflows/issue-template-check.yml b/.github/workflows/issue-template-check.yml index b49647d33..1af139054 100644 --- a/.github/workflows/issue-template-check.yml +++ b/.github/workflows/issue-template-check.yml @@ -10,7 +10,7 @@ jobs: issues: write steps: - name: pull in script - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: repository: jellyfin/jellyfin-triage-script - name: install python diff --git a/.github/workflows/release-bump-version.yaml b/.github/workflows/release-bump-version.yaml index ec91744f3..fa4025de5 100644 --- a/.github/workflows/release-bump-version.yaml +++ b/.github/workflows/release-bump-version.yaml @@ -33,7 +33,7 @@ jobs: yq-version: v4.9.8 - name: Checkout Repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: ref: ${{ env.TAG_BRANCH }} @@ -66,7 +66,7 @@ jobs: NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }} steps: - name: Checkout Repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: ref: ${{ env.TAG_BRANCH }} diff --git a/Directory.Packages.props b/Directory.Packages.props index 12aab3828..210cbd35d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,7 +17,7 @@ <PackageVersion Include="Diacritics" Version="4.0.17" /> <PackageVersion Include="DiscUtils.Udf" Version="0.16.13" /> <PackageVersion Include="DotNet.Glob" Version="3.1.3" /> - <PackageVersion Include="FsCheck.Xunit" Version="3.3.1" /> + <PackageVersion Include="FsCheck.Xunit" Version="3.3.2" /> <PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.1" /> <PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" /> <PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" /> @@ -26,33 +26,33 @@ <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.10" /> - <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.10" /> + <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.11" /> + <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.11" /> <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.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="Microsoft.Data.Sqlite" Version="9.0.11" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.11" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.11" /> + <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.11" /> + <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.11" /> + <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.11" /> + <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.11" /> + <PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.11" /> + <PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.11" /> + <PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.11" /> + <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.11" /> + <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.11" /> + <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.11" /> + <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.11" /> + <PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.11" /> + <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.11" /> + <PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.11" /> + <PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.11" /> + <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" /> <PackageVersion Include="MimeTypes" Version="2.5.2" /> <PackageVersion Include="Morestachio" Version="5.0.1.631" /> <PackageVersion Include="Moq" Version="4.18.4" /> @@ -62,13 +62,13 @@ <PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" /> <PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" /> <PackageVersion Include="prometheus-net" Version="8.2.1" /> - <PackageVersion Include="Polly" Version="8.6.4" /> + <PackageVersion Include="Polly" Version="8.6.5" /> <PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" /> <PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" /> <PackageVersion Include="Serilog.Expressions" Version="5.0.0" /> <PackageVersion Include="Serilog.Settings.Configuration" Version="9.0.0" /> <PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" /> - <PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" /> + <PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" /> <PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" /> <PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" /> <PackageVersion Include="SerilogAnalyzer" Version="0.15.0" /> @@ -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.10" /> - <PackageVersion Include="System.Text.Json" Version="9.0.10" /> - <PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.10" /> + <PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.11" /> + <PackageVersion Include="System.Text.Json" Version="9.0.11" /> + <PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.11" /> <PackageVersion Include="TagLibSharp" Version="2.3.0" /> - <PackageVersion Include="z440.atl.core" Version="7.6.0" /> + <PackageVersion Include="z440.atl.core" Version="7.9.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/TV/SeasonPathParser.cs b/Emby.Naming/TV/SeasonPathParser.cs index 98ee1e4b8..72adfb2d9 100644 --- a/Emby.Naming/TV/SeasonPathParser.cs +++ b/Emby.Naming/TV/SeasonPathParser.cs @@ -10,12 +10,17 @@ 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>.*)$")] + private static readonly Regex CleanNameRegex = new(@"[ ._\-\[\]]", RegexOptions.Compiled); + + [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+?)(?=\d{3,4}p|[^\d]|$)(?!\s*[Ee]\d)(?<rightpart>.*)$", RegexOptions.IgnoreCase)] private static partial Regex ProcessPost(); + [GeneratedRegex(@"[sS](\d{1,4})(?!\d|[eE]\d)(?=\.|_|-|\[|\]|\s|$)", RegexOptions.None)] + private static partial Regex SeasonPrefix(); + /// <summary> /// Attempts to parse season number from path. /// </summary> @@ -56,44 +61,34 @@ namespace Emby.Naming.TV bool supportSpecialAliases, bool supportNumericSeasonFolders) { - string filename = Path.GetFileName(path); - filename = Regex.Replace(filename, "[ ._-]", string.Empty); + var fileName = Path.GetFileName(path); - if (parentFolderName is not null) + var seasonPrefixMatch = SeasonPrefix().Match(fileName); + if (seasonPrefixMatch.Success && + int.TryParse(seasonPrefixMatch.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val)) { - parentFolderName = Regex.Replace(parentFolderName, "[ ._-]", string.Empty); - filename = filename.Replace(parentFolderName, string.Empty, StringComparison.OrdinalIgnoreCase); + return (val, true); } - if (supportSpecialAliases) - { - if (string.Equals(filename, "specials", StringComparison.OrdinalIgnoreCase)) - { - return (0, true); - } + string filename = CleanNameRegex.Replace(fileName, string.Empty); - if (string.Equals(filename, "extras", StringComparison.OrdinalIgnoreCase)) - { - return (0, true); - } + if (parentFolderName is not null) + { + var cleanParent = CleanNameRegex.Replace(parentFolderName, string.Empty); + filename = filename.Replace(cleanParent, string.Empty, StringComparison.OrdinalIgnoreCase); } - if (supportNumericSeasonFolders) + if (supportSpecialAliases && + (filename.Equals("specials", StringComparison.OrdinalIgnoreCase) || + filename.Equals("extras", StringComparison.OrdinalIgnoreCase))) { - if (int.TryParse(filename, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val)) - { - return (val, true); - } + return (0, true); } - if (filename.StartsWith('s')) + if (supportNumericSeasonFolders && + int.TryParse(filename, NumberStyles.Integer, CultureInfo.InvariantCulture, out val)) { - var testFilename = filename.AsSpan()[1..]; - - if (int.TryParse(testFilename, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val)) - { - return (val, true); - } + return (val, true); } var preMatch = ProcessPre().Match(filename); @@ -113,8 +108,10 @@ namespace Emby.Naming.TV var numberString = match.Groups["seasonnumber"]; if (numberString.Success) { - var seasonNumber = int.Parse(numberString.Value, CultureInfo.InvariantCulture); - return (seasonNumber, true); + if (int.TryParse(numberString.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seasonNumber)) + { + return (seasonNumber, true); + } } return (null, false); diff --git a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs index c69bcfef7..de722332a 100644 --- a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs +++ b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs @@ -107,10 +107,20 @@ namespace Emby.Server.Implementations.AppBase private void CheckOrCreateMarker(string path, string markerName, bool recursive = false) { - var otherMarkers = GetMarkers(path, recursive).FirstOrDefault(e => Path.GetFileName(e) != markerName); + string? otherMarkers = null; + try + { + otherMarkers = GetMarkers(path, recursive).FirstOrDefault(e => !Path.GetFileName(e.AsSpan()).Equals(markerName, StringComparison.OrdinalIgnoreCase)); + } + catch + { + // Error while checking for marker files, assume none exist and keep going + // TODO: add some logging + } + if (otherMarkers is not null) { - throw new InvalidOperationException($"Exepected to find only {markerName} but found marker for {otherMarkers}."); + throw new InvalidOperationException($"Expected to find only {markerName} but found marker for {otherMarkers}."); } var markerPath = Path.Combine(path, markerName); diff --git a/Emby.Server.Implementations/Chapters/ChapterManager.cs b/Emby.Server.Implementations/Chapters/ChapterManager.cs index fea05931d..d09ed30ae 100644 --- a/Emby.Server.Implementations/Chapters/ChapterManager.cs +++ b/Emby.Server.Implementations/Chapters/ChapterManager.cs @@ -223,7 +223,7 @@ public class ChapterManager : IChapterManager if (saveChapters && changesMade) { - _chapterRepository.SaveChapters(video.Id, chapters); + SaveChapters(video, chapters); } DeleteDeadImages(currentImages, chapters); @@ -234,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 /> diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs index c9630b894..fad97344b 100644 --- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs +++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Security; using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.IO; using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; @@ -152,6 +153,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 +253,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 = FileSystemHelper.ResolveLinkTarget(fileInfo, 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 bafe3ad43..473ff8e1d 100644 --- a/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs +++ b/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs @@ -1,6 +1,7 @@ using System; using System.IO; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Resolvers; using MediaBrowser.Model.IO; @@ -11,28 +12,24 @@ namespace Emby.Server.Implementations.Library; /// </summary> public class DotIgnoreIgnoreRule : IResolverIgnoreRule { + private static readonly bool IsWindows = OperatingSystem.IsWindows(); + private static FileInfo? FindIgnoreFile(DirectoryInfo directory) { - var ignoreFile = new FileInfo(Path.Join(directory.FullName, ".ignore")); - if (ignoreFile.Exists) - { - return ignoreFile; - } - - var parentDir = directory.Parent; - if (parentDir is null) + for (var current = directory; current is not null; current = current.Parent) { - return null; + var ignorePath = Path.Join(current.FullName, ".ignore"); + if (File.Exists(ignorePath)) + { + return new FileInfo(ignorePath); + } } - return FindIgnoreFile(parentDir); + return null; } /// <inheritdoc /> - public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent) - { - return IsIgnored(fileInfo, parent); - } + public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent) => IsIgnored(fileInfo, parent); /// <summary> /// Checks whether or not the file is ignored. @@ -42,60 +39,58 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule /// <returns>True if the file should be ignored.</returns> public static bool IsIgnored(FileSystemMetadata fileInfo, BaseItem? parent) { - if (fileInfo.IsDirectory) - { - var dirIgnoreFile = FindIgnoreFile(new DirectoryInfo(fileInfo.FullName)); - if (dirIgnoreFile is null) - { - return false; - } - - // Fast path in case the ignore files isn't a symlink and is empty - if ((dirIgnoreFile.Attributes & FileAttributes.ReparsePoint) == 0 - && dirIgnoreFile.Length == 0) - { - return true; - } - - // ignore the directory only if the .ignore file is empty - // evaluate individual files otherwise - return string.IsNullOrWhiteSpace(GetFileContent(dirIgnoreFile)); - } + var searchDirectory = fileInfo.IsDirectory + ? new DirectoryInfo(fileInfo.FullName) + : new DirectoryInfo(Path.GetDirectoryName(fileInfo.FullName) ?? string.Empty); - var parentDirPath = Path.GetDirectoryName(fileInfo.FullName); - if (string.IsNullOrEmpty(parentDirPath)) + if (string.IsNullOrEmpty(searchDirectory.FullName)) { return false; } - var folder = new DirectoryInfo(parentDirPath); - var ignoreFile = FindIgnoreFile(folder); + var ignoreFile = FindIgnoreFile(searchDirectory); if (ignoreFile is null) { return false; } - string ignoreFileString = GetFileContent(ignoreFile); - - if (string.IsNullOrWhiteSpace(ignoreFileString)) + // Fast path in case the ignore files isn't a symlink and is empty + if (ignoreFile.LinkTarget is null && ignoreFile.Length == 0) { // Ignore directory if we just have the file return true; } + var content = GetFileContent(ignoreFile); + return string.IsNullOrWhiteSpace(content) + || CheckIgnoreRules(fileInfo.FullName, content, fileInfo.IsDirectory); + } + + private static bool CheckIgnoreRules(string path, string ignoreFileContent, bool isDirectory) + { // If file has content, base ignoring off the content .gitignore-style rules - var ignoreRules = ignoreFileString.Split('\n', StringSplitOptions.RemoveEmptyEntries); + var rules = ignoreFileContent.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var ignore = new Ignore.Ignore(); - ignore.Add(ignoreRules); + ignore.Add(rules); - return ignore.IsIgnored(fileInfo.FullName); - } + // Mitigate the problem of the Ignore library not handling Windows paths correctly. + // See https://github.com/jellyfin/jellyfin/issues/15484 + var pathToCheck = IsWindows ? path.NormalizePath('/') : path; - private static string GetFileContent(FileInfo dirIgnoreFile) - { - using (var reader = dirIgnoreFile.OpenText()) + // Add trailing slash for directories to match "folder/" + if (isDirectory) { - return reader.ReadToEnd(); + pathToCheck = string.Concat(pathToCheck.AsSpan().TrimEnd('/'), "/"); } + + return ignore.IsIgnored(pathToCheck); + } + + private static string GetFileContent(FileInfo ignoreFile) + { + ignoreFile = FileSystemHelper.ResolveLinkTarget(ignoreFile, returnFinalTarget: true) ?? ignoreFile; + return ignoreFile.Exists + ? File.ReadAllText(ignoreFile.FullName) + : string.Empty; } } diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index a400cb092..cab87e53d 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -457,6 +457,12 @@ namespace Emby.Server.Implementations.Library _cache.TryRemove(child.Id, out _); } + if (parent is Folder folder) + { + folder.Children = null; + folder.UserData = null; + } + ReportItemRemoved(item, parent); } @@ -1993,6 +1999,12 @@ namespace Emby.Server.Implementations.Library RegisterItem(item); } + if (parent is Folder folder) + { + folder.Children = null; + folder.UserData = null; + } + if (ItemAdded is not null) { foreach (var item in items) @@ -2150,6 +2162,12 @@ namespace Emby.Server.Implementations.Library _itemRepository.SaveItems(items, cancellationToken); + if (parent is Folder folder) + { + folder.Children = null; + folder.UserData = null; + } + if (ItemUpdated is not null) { foreach (var item in items) diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index 750346169..c667fb060 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -226,6 +226,11 @@ namespace Emby.Server.Implementations.Library /// <inheritdoc />> public MediaProtocol GetPathProtocol(string path) { + if (string.IsNullOrEmpty(path)) + { + return MediaProtocol.File; + } + if (path.StartsWith("Rtsp", StringComparison.OrdinalIgnoreCase)) { return MediaProtocol.Rtsp; diff --git a/Emby.Server.Implementations/Library/MusicManager.cs b/Emby.Server.Implementations/Library/MusicManager.cs index e0c8ae371..e19ad3ef6 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/Library/Resolvers/Movies/MovieResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs index 333c8c34b..98e8f5350 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs @@ -369,13 +369,16 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies // We need to only look at the name of this actual item (not parents) var justName = item.IsInMixedFolder ? Path.GetFileName(item.Path.AsSpan()) : Path.GetFileName(item.ContainingFolderPath.AsSpan()); - if (!justName.IsEmpty) + var tmdbid = justName.GetAttributeValue("tmdbid"); + + // If not in a mixed folder and ID not found in folder path, check filename + if (string.IsNullOrEmpty(tmdbid) && !item.IsInMixedFolder) { - // Check for TMDb id - var tmdbid = justName.GetAttributeValue("tmdbid"); - item.TrySetProviderId(MetadataProvider.Tmdb, tmdbid); + tmdbid = Path.GetFileName(item.Path.AsSpan()).GetAttributeValue("tmdbid"); } + item.TrySetProviderId(MetadataProvider.Tmdb, tmdbid); + if (!string.IsNullOrEmpty(item.Path)) { // Check for IMDb id - we use full media path, as we can assume that this will match in any use case (whether id in parent dir or in file name) diff --git a/Emby.Server.Implementations/Localization/Core/et.json b/Emby.Server.Implementations/Localization/Core/et.json index a3f9dc2f8..2e692009b 100644 --- a/Emby.Server.Implementations/Localization/Core/et.json +++ b/Emby.Server.Implementations/Localization/Core/et.json @@ -137,5 +137,5 @@ "TaskExtractMediaSegmentsDescription": "Eraldab või võtab meediasegmendid MediaSegment'i lubavatest pluginatest.", "TaskMoveTrickplayImages": "Muuda trickplay piltide asukoht", "CleanupUserDataTask": "Puhasta kasutajaandmed", - "CleanupUserDataTaskDescription": "Puhastab kõik kasutajaandmed (vaatamise olek, lemmikute olek jne) meediast, mis pole enam vähemalt 90 päeva saadaval olnud." + "CleanupUserDataTaskDescription": "Puhastab kõik kasutajaandmed (vaatamise olek, lemmikute olek jne) meediast, mida 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 f847d83d1..e1ee8cf7c 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/mn.json b/Emby.Server.Implementations/Localization/Core/mn.json index cf39df706..a684ff204 100644 --- a/Emby.Server.Implementations/Localization/Core/mn.json +++ b/Emby.Server.Implementations/Localization/Core/mn.json @@ -109,9 +109,9 @@ "ScheduledTaskStartedWithName": "{0}-г эхлүүлэв", "ServerNameNeedsToBeRestarted": "{0}-г дахин асаана уу", "Shows": "Шоу", - "Sync": "Дахин", + "Sync": "Синхрончлох", "System": "Систем", - "TvShows": "Цуварлууд", + "TvShows": "ТВ нэвтрүүлгүүд", "Undefined": "Танисангүй", "User": "Хэрэглэгч", "UserCreatedWithName": "Хэрэглэгч {0}-г үүсгэлээ", diff --git a/Emby.Server.Implementations/Localization/Core/pr.json b/Emby.Server.Implementations/Localization/Core/pr.json index 9076b9c87..fee7e65f1 100644 --- a/Emby.Server.Implementations/Localization/Core/pr.json +++ b/Emby.Server.Implementations/Localization/Core/pr.json @@ -16,7 +16,7 @@ "Collections": "Barrels", "ItemAddedWithName": "{0} is now with yer treasure", "Default": "Normal-like", - "FailedLoginAttemptWithUserName": "Ye failed to get in, try from {0}", + "FailedLoginAttemptWithUserName": "Ye failed to enter from {0}", "Favorites": "Finest Loot", "ItemRemovedWithName": "{0} was taken from yer treasure", "LabelIpAddressValue": "Ship's coordinates: {0}", @@ -113,5 +113,10 @@ "TaskCleanCache": "Sweep the Cache Chest", "TaskRefreshChapterImages": "Claim chapter portraits", "TaskRefreshChapterImagesDescription": "Paints wee portraits fer videos that own chapters.", - "TaskRefreshLibrary": "Scan the Treasure Trove" + "TaskRefreshLibrary": "Scan the Treasure Trove", + "TasksChannelsCategory": "Channels o' thy Internet", + "TaskRefreshTrickplayImages": "Summon the picture tricks", + "TaskRefreshTrickplayImagesDescription": "Summons picture trick previews for videos in ye enabled book roost", + "TaskUpdatePlugins": "Resummon yer Plugins", + "TaskCleanTranscode": "Swab Ye Transcode Directory" } diff --git a/Emby.Server.Implementations/Localization/Core/pt-PT.json b/Emby.Server.Implementations/Localization/Core/pt-PT.json index f188822d6..c3eba362d 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-PT.json +++ b/Emby.Server.Implementations/Localization/Core/pt-PT.json @@ -5,7 +5,7 @@ "Artists": "Artistas", "AuthenticationSucceededWithUserName": "{0} autenticado com sucesso", "Books": "Livros", - "CameraImageUploadedFrom": "Uma nova imagem de câmara foi enviada a partir de {0}", + "CameraImageUploadedFrom": "Uma nova imagem da câmara foi enviada a partir de {0}", "Channels": "Canais", "ChapterNameValue": "Capítulo {0}", "Collections": "Coleções", diff --git a/Emby.Server.Implementations/Localization/Core/vi.json b/Emby.Server.Implementations/Localization/Core/vi.json index d1c5166cb..3f4bf1f7f 100644 --- a/Emby.Server.Implementations/Localization/Core/vi.json +++ b/Emby.Server.Implementations/Localization/Core/vi.json @@ -39,7 +39,7 @@ "TasksMaintenanceCategory": "Bảo Trì", "VersionNumber": "Phiên Bản {0}", "ValueHasBeenAddedToLibrary": "{0} đã được thêm vào thư viện của bạn", - "UserStoppedPlayingItemWithValues": "{0} đã phát xong {1} trên {2}", + "UserStoppedPlayingItemWithValues": "{0} đã kết thúc phát {1} trên {2}", "UserStartedPlayingItemWithValues": "{0} đang phát {1} trên {2}", "UserPolicyUpdatedWithName": "Chính sách người dùng đã được cập nhật cho {0}", "UserPasswordChangedWithName": "Mật khẩu đã được thay đổi cho người dùng {0}", diff --git a/Emby.Server.Implementations/Localization/Core/zh-HK.json b/Emby.Server.Implementations/Localization/Core/zh-HK.json index 39141d841..c8800e256 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": "繼承", @@ -127,8 +127,8 @@ "HearingImpaired": "聽力障礙", "TaskRefreshTrickplayImages": "建立 Trickplay 圖像", "TaskRefreshTrickplayImagesDescription": "為已啟用 Trickplay 的媒體庫內的影片建立 Trickplay 預覽圖。", - "TaskExtractMediaSegments": "掃描媒體段落", - "TaskExtractMediaSegmentsDescription": "從MediaSegment中被允許的插件獲取媒體段落。", + "TaskExtractMediaSegments": "掃描媒體分段資訊", + "TaskExtractMediaSegmentsDescription": "從允許MediaSegment 功能的插件中獲取媒體片段。", "TaskDownloadMissingLyrics": "下載欠缺歌詞", "TaskDownloadMissingLyricsDescription": "下載歌詞", "TaskCleanCollectionsAndPlaylists": "整理媒體與播放清單", @@ -137,5 +137,6 @@ "TaskCleanCollectionsAndPlaylistsDescription": "從資料庫及播放清單中移除已不存在的項目。", "TaskMoveTrickplayImagesDescription": "根據媒體庫設定移動現有的 Trickplay 檔案。", "TaskMoveTrickplayImages": "轉移 Trickplay 影像位置", - "CleanupUserDataTask": "用戶資料清理工作" + "CleanupUserDataTask": "用戶資料清理工作", + "CleanupUserDataTaskDescription": "從用戶數據中清除已經被刪除超過 90 日的媒體相關資料。" } diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs index c9d76df0b..1577c5c9c 100644 --- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs @@ -244,6 +244,7 @@ namespace Emby.Server.Implementations.Playlists // Update the playlist in the repository playlist.LinkedChildren = [.. playlist.LinkedChildren, .. childrenToAdd]; + playlist.DateLastMediaAdded = DateTime.UtcNow; await UpdatePlaylistInternal(playlist).ConfigureAwait(false); diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs index 678475b31..5ff400160 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/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index 2614fe995..fe6f855b5 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -1625,8 +1625,11 @@ public class DynamicHlsController : BaseJellyfinApiController var useLegacySegmentOption = _mediaEncoder.EncoderVersion < _minFFmpegHlsSegmentOptions; - // fMP4 needs this flag to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT - hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont"; + if (state.VideoStream is not null && state.IsOutputVideo) + { + // fMP4 needs this flag to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT + hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont"; + } segmentFormat = "fmp4" + outputFmp4HeaderArg; } diff --git a/Jellyfin.Api/Formatters/XmlOutputFormatter.cs b/Jellyfin.Api/Formatters/XmlOutputFormatter.cs index 8dbb91d0a..46256c09d 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.Server.Implementations/FullSystemBackup/BackupService.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs index e5c3cef3d..70483c36c 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 eb88eac00..2c18ce69a 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs @@ -275,6 +275,7 @@ public sealed class BaseItemRepository } dbQuery = ApplyQueryPaging(dbQuery, filter); + dbQuery = ApplyNavigations(dbQuery, filter); result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray(); result.StartIndex = filter.StartIndex ?? 0; @@ -294,6 +295,7 @@ public sealed class BaseItemRepository dbQuery = ApplyGroupingFilter(context, dbQuery, filter); dbQuery = ApplyQueryPaging(dbQuery, filter); + dbQuery = ApplyNavigations(dbQuery, filter); return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray(); } @@ -337,6 +339,8 @@ public sealed class BaseItemRepository mainquery = ApplyGroupingFilter(context, mainquery, filter); mainquery = ApplyQueryPaging(mainquery, filter); + mainquery = ApplyNavigations(mainquery, filter); + return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray(); } @@ -399,9 +403,7 @@ public sealed class BaseItemRepository dbQuery = dbQuery.Distinct(); } - dbQuery = ApplyOrder(dbQuery, filter); - - dbQuery = ApplyNavigations(dbQuery, filter); + dbQuery = ApplyOrder(dbQuery, filter, context); return dbQuery; } @@ -446,6 +448,7 @@ public sealed class BaseItemRepository dbQuery = TranslateQuery(dbQuery, context, filter); dbQuery = ApplyGroupingFilter(context, dbQuery, filter); dbQuery = ApplyQueryPaging(dbQuery, filter); + dbQuery = ApplyNavigations(dbQuery, filter); return dbQuery; } @@ -614,6 +617,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; } } @@ -1245,7 +1255,7 @@ public sealed class BaseItemRepository .AsSingleQuery() .Where(e => masterQuery.Contains(e.Id)); - query = ApplyOrder(query, filter); + query = ApplyOrder(query, filter, context); var result = new QueryResult<(BaseItemDto, ItemCounts?)>(); if (filter.EnableTotalRecordCount) @@ -1511,7 +1521,7 @@ public sealed class BaseItemRepository || query.IncludeItemTypes.Contains(BaseItemKind.Season); } - private IQueryable<BaseItemEntity> ApplyOrder(IQueryable<BaseItemEntity> query, InternalItemsQuery filter) + private IQueryable<BaseItemEntity> ApplyOrder(IQueryable<BaseItemEntity> query, InternalItemsQuery filter, JellyfinDbContext context) { var orderBy = filter.OrderBy; var hasSearch = !string.IsNullOrEmpty(filter.SearchTerm); @@ -1530,7 +1540,7 @@ public sealed class BaseItemRepository var firstOrdering = orderBy.FirstOrDefault(); if (firstOrdering != default) { - var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter); + var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter, context); if (firstOrdering.SortOrder == SortOrder.Ascending) { orderedQuery = query.OrderBy(expression); @@ -1555,7 +1565,7 @@ public sealed class BaseItemRepository foreach (var item in orderBy.Skip(1)) { - var expression = OrderMapper.MapOrderByField(item.OrderBy, filter); + var expression = OrderMapper.MapOrderByField(item.OrderBy, filter, context); if (item.SortOrder == SortOrder.Ascending) { orderedQuery = orderedQuery!.ThenBy(expression); @@ -1694,15 +1704,16 @@ public sealed class BaseItemRepository if (!string.IsNullOrEmpty(filter.SearchTerm)) { - var searchTerm = filter.SearchTerm.ToLower(); - if (SearchWildcardTerms.Any(f => searchTerm.Contains(f))) + var cleanedSearchTerm = GetCleanValue(filter.SearchTerm); + var originalSearchTerm = filter.SearchTerm.ToLower(); + if (SearchWildcardTerms.Any(f => cleanedSearchTerm.Contains(f))) { - searchTerm = $"%{searchTerm.Trim('%')}%"; - baseQuery = baseQuery.Where(e => EF.Functions.Like(e.CleanName!.ToLower(), searchTerm) || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle.ToLower(), searchTerm))); + cleanedSearchTerm = $"%{cleanedSearchTerm.Trim('%')}%"; + baseQuery = baseQuery.Where(e => EF.Functions.Like(e.CleanName!, cleanedSearchTerm) || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle.ToLower(), originalSearchTerm))); } else { - baseQuery = baseQuery.Where(e => e.CleanName!.ToLower().Contains(searchTerm) || (e.OriginalTitle != null && e.OriginalTitle.ToLower().Contains(searchTerm))); + baseQuery = baseQuery.Where(e => e.CleanName!.Contains(cleanedSearchTerm) || (e.OriginalTitle != null && e.OriginalTitle.ToLower().Contains(originalSearchTerm))); } } @@ -1756,7 +1767,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)) @@ -1936,19 +1948,20 @@ public sealed class BaseItemRepository if (!string.IsNullOrWhiteSpace(filter.NameStartsWith)) { - baseQuery = baseQuery.Where(e => e.SortName!.StartsWith(filter.NameStartsWith)); + var startsWithLower = filter.NameStartsWith.ToLowerInvariant(); + baseQuery = baseQuery.Where(e => e.SortName!.StartsWith(startsWithLower)); } if (!string.IsNullOrWhiteSpace(filter.NameStartsWithOrGreater)) { - // i hate this - baseQuery = baseQuery.Where(e => e.SortName!.FirstOrDefault() > filter.NameStartsWithOrGreater[0] || e.Name!.FirstOrDefault() > filter.NameStartsWithOrGreater[0]); + var startsOrGreaterLower = filter.NameStartsWithOrGreater.ToLowerInvariant(); + baseQuery = baseQuery.Where(e => e.SortName!.CompareTo(startsOrGreaterLower) >= 0); } if (!string.IsNullOrWhiteSpace(filter.NameLessThan)) { - // i hate this - baseQuery = baseQuery.Where(e => e.SortName!.FirstOrDefault() < filter.NameLessThan[0] || e.Name!.FirstOrDefault() < filter.NameLessThan[0]); + var lessThanLower = filter.NameLessThan.ToLowerInvariant(); + baseQuery = baseQuery.Where(e => e.SortName!.CompareTo(lessThanLower ) < 0); } if (filter.ImageTypes.Length > 0) @@ -2046,7 +2059,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) @@ -2353,17 +2366,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; @@ -2401,39 +2420,34 @@ public sealed class BaseItemRepository if (filter.ExcludeInheritedTags.Length > 0) { - baseQuery = baseQuery - .Where(e => !e.ItemValues!.Where(w => w.ItemValue.Type == ItemValueType.InheritedTags || w.ItemValue.Type == ItemValueType.Tags) - .Any(f => filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue))); + baseQuery = baseQuery.Where(e => + !e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue)) + && (e.Type != _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode] || !e.SeriesId.HasValue || + !context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue)))); } if (filter.IncludeInheritedTags.Length > 0) { - // Episodes do not store inherit tags from their parents in the database, and the tag may be still required by the client. - // In addition to the tags for the episodes themselves, we need to manually query its parent (the season)'s tags as well. - if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Episode) + // For seasons and episodes, we also need to check the parent series' tags. + if (includeTypes.Any(t => t == BaseItemKind.Episode || t == BaseItemKind.Season)) { - baseQuery = baseQuery - .Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags) - .Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)) - || - (e.ParentId.HasValue && context.ItemValuesMap.Where(w => w.ItemId == e.ParentId.Value && (w.ItemValue.Type == ItemValueType.InheritedTags || w.ItemValue.Type == ItemValueType.Tags)) - .Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)))); + baseQuery = baseQuery.Where(e => + e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)) + || (e.SeriesId.HasValue && context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)))); } // A playlist should be accessible to its owner regardless of allowed tags. else if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Playlist) { - baseQuery = baseQuery - .Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags) - .Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)) - || e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\"")); + baseQuery = baseQuery.Where(e => + e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)) + || e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\"")); // d ^^ this is stupid it hate this. } else { - baseQuery = baseQuery - .Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags) - .Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))); + baseQuery = baseQuery.Where(e => + e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))); } } diff --git a/Jellyfin.Server.Implementations/Item/OrderMapper.cs b/Jellyfin.Server.Implementations/Item/OrderMapper.cs index a0c127031..192ee7499 100644 --- a/Jellyfin.Server.Implementations/Item/OrderMapper.cs +++ b/Jellyfin.Server.Implementations/Item/OrderMapper.cs @@ -1,7 +1,10 @@ +#pragma warning disable RS0030 // Do not use banned APIs + using System; using System.Linq; using System.Linq.Expressions; using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using Microsoft.EntityFrameworkCore; @@ -18,39 +21,50 @@ public static class OrderMapper /// </summary> /// <param name="sortBy">Item property to sort by.</param> /// <param name="query">Context Query.</param> + /// <param name="jellyfinDbContext">Context.</param> /// <returns>Func to be executed later for sorting query.</returns> - public static Expression<Func<BaseItemEntity, object?>> MapOrderByField(ItemSortBy sortBy, InternalItemsQuery query) + public static Expression<Func<BaseItemEntity, object?>> MapOrderByField(ItemSortBy sortBy, InternalItemsQuery query, JellyfinDbContext jellyfinDbContext) { - return sortBy switch + return (sortBy, query.User) switch { - ItemSortBy.AirTime => e => e.SortName, // TODO - ItemSortBy.Runtime => e => e.RunTimeTicks, - ItemSortBy.Random => e => EF.Functions.Random(), - ItemSortBy.DatePlayed => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.LastPlayedDate, - ItemSortBy.PlayCount => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.PlayCount, - ItemSortBy.IsFavoriteOrLiked => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.IsFavorite, - ItemSortBy.IsFolder => e => e.IsFolder, - ItemSortBy.IsPlayed => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played, - ItemSortBy.IsUnplayed => e => !e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played, - ItemSortBy.DateLastContentAdded => e => e.DateLastMediaAdded, - ItemSortBy.Artist => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Artist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(), - ItemSortBy.AlbumArtist => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.AlbumArtist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(), - ItemSortBy.Studio => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Studios).Select(f => f.ItemValue.CleanValue).FirstOrDefault(), - ItemSortBy.OfficialRating => e => e.InheritedParentalRatingValue, - // ItemSortBy.SeriesDatePlayed => "(Select MAX(LastPlayedDate) from TypedBaseItems B" + GetJoinUserDataText(query) + " where Played=1 and B.SeriesPresentationUniqueKey=A.PresentationUniqueKey)", - ItemSortBy.SeriesSortName => e => e.SeriesName, + (ItemSortBy.AirTime, _) => e => e.SortName, // TODO + (ItemSortBy.Runtime, _) => e => e.RunTimeTicks, + (ItemSortBy.Random, _) => e => EF.Functions.Random(), + (ItemSortBy.DatePlayed, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.LastPlayedDate, + (ItemSortBy.PlayCount, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.PlayCount, + (ItemSortBy.IsFavoriteOrLiked, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.IsFavorite, + (ItemSortBy.IsFolder, _) => e => e.IsFolder, + (ItemSortBy.IsPlayed, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played, + (ItemSortBy.IsUnplayed, _) => e => !e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played, + (ItemSortBy.DateLastContentAdded, _) => e => e.DateLastMediaAdded, + (ItemSortBy.Artist, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Artist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(), + (ItemSortBy.AlbumArtist, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.AlbumArtist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(), + (ItemSortBy.Studio, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Studios).Select(f => f.ItemValue.CleanValue).FirstOrDefault(), + (ItemSortBy.OfficialRating, _) => e => e.InheritedParentalRatingValue, + (ItemSortBy.SeriesSortName, _) => e => e.SeriesName, + (ItemSortBy.Album, _) => e => e.Album, + (ItemSortBy.DateCreated, _) => e => e.DateCreated, + (ItemSortBy.PremiereDate, _) => e => (e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null)), + (ItemSortBy.StartDate, _) => e => e.StartDate, + (ItemSortBy.Name, _) => e => e.CleanName, + (ItemSortBy.CommunityRating, _) => e => e.CommunityRating, + (ItemSortBy.ProductionYear, _) => e => e.ProductionYear, + (ItemSortBy.CriticRating, _) => e => e.CriticRating, + (ItemSortBy.VideoBitRate, _) => e => e.TotalBitrate, + (ItemSortBy.ParentIndexNumber, _) => e => e.ParentIndexNumber, + (ItemSortBy.IndexNumber, _) => e => e.IndexNumber, + (ItemSortBy.SeriesDatePlayed, not null) => e => + jellyfinDbContext.BaseItems + .Where(w => w.SeriesPresentationUniqueKey == e.PresentationUniqueKey) + .Join(jellyfinDbContext.UserData.Where(w => w.UserId == query.User.Id && w.Played), f => f.Id, f => f.ItemId, (item, userData) => userData.LastPlayedDate) + .Max(f => f), + (ItemSortBy.SeriesDatePlayed, null) => e => jellyfinDbContext.BaseItems.Where(w => w.SeriesPresentationUniqueKey == e.PresentationUniqueKey) + .Join(jellyfinDbContext.UserData.Where(w => w.Played), f => f.Id, f => f.ItemId, (item, userData) => userData.LastPlayedDate) + .Max(f => f), + // ItemSortBy.SeriesDatePlayed => e => jellyfinDbContext.UserData + // .Where(u => u.Item!.SeriesPresentationUniqueKey == e.PresentationUniqueKey && u.Played) + // .Max(f => f.LastPlayedDate), // ItemSortBy.AiredEpisodeOrder => "AiredEpisodeOrder", - ItemSortBy.Album => e => e.Album, - ItemSortBy.DateCreated => e => e.DateCreated, - ItemSortBy.PremiereDate => e => (e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null)), - ItemSortBy.StartDate => e => e.StartDate, - ItemSortBy.Name => e => e.CleanName, - ItemSortBy.CommunityRating => e => e.CommunityRating, - ItemSortBy.ProductionYear => e => e.ProductionYear, - ItemSortBy.CriticRating => e => e.CriticRating, - ItemSortBy.VideoBitRate => e => e.TotalBitrate, - ItemSortBy.ParentIndexNumber => e => e.ParentIndexNumber, - ItemSortBy.IndexNumber => e => e.IndexNumber, _ => e => e.SortName }; } diff --git a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs index b2f54be7e..ce628a04d 100644 --- a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs +++ b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs @@ -13,8 +13,7 @@ namespace Jellyfin.Server.Implementations.StorageHelpers; 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. @@ -24,10 +23,8 @@ public static class StorageHelper public static void TestCommonPathsForStorageCapacity(IApplicationPaths applicationPaths, ILogger logger) { TestDataDirectorySize(applicationPaths.DataPath, logger, TwoGigabyte); - TestDataDirectorySize(applicationPaths.LogDirectoryPath, logger, FiveHundredAndTwelveMegaByte); TestDataDirectorySize(applicationPaths.CachePath, logger, TwoGigabyte); TestDataDirectorySize(applicationPaths.ProgramDataPath, logger, TwoGigabyte); - TestDataDirectorySize(applicationPaths.TempDirectory, logger, TwoGigabyte); } /// <summary> @@ -77,7 +74,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.Implementations/Trickplay/TrickplayManager.cs b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs index 6f2d2a107..4505a377c 100644 --- a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs +++ b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs @@ -254,10 +254,10 @@ public class TrickplayManager : ITrickplayManager } // We support video backdrops, but we should not generate trickplay images for them - var parentDirectory = Directory.GetParent(mediaPath); + var parentDirectory = Directory.GetParent(video.Path); if (parentDirectory is not null && string.Equals(parentDirectory.Name, "backdrops", StringComparison.OrdinalIgnoreCase)) { - _logger.LogDebug("Ignoring backdrop media found at {Path} for item {ItemID}", mediaPath, video.Id); + _logger.LogDebug("Ignoring backdrop media found at {Path} for item {ItemID}", video.Path, video.Id); return; } diff --git a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs index f20fb2d92..49a9fda94 100644 --- a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs +++ b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Security.Cryptography; using System.Text.Json; @@ -92,33 +93,38 @@ namespace Jellyfin.Server.Implementations.Users } /// <inheritdoc /> - public async Task<ForgotPasswordResult> StartForgotPasswordProcess(User user, bool isInNetwork) + public async Task<ForgotPasswordResult> StartForgotPasswordProcess(User? user, string enteredUsername, bool isInNetwork) { - byte[] bytes = new byte[4]; - RandomNumberGenerator.Fill(bytes); - string pin = BitConverter.ToString(bytes); - DateTime expireTime = DateTime.UtcNow.AddMinutes(30); - string filePath = _passwordResetFileBase + user.Id + ".json"; - SerializablePasswordReset spr = new SerializablePasswordReset - { - ExpirationDate = expireTime, - Pin = pin, - PinFile = filePath, - UserName = user.Username - }; + var usernameHash = enteredUsername.ToUpperInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture); + var pinFile = _passwordResetFileBase + usernameHash + ".json"; - FileStream fileStream = AsyncFile.Create(filePath); - await using (fileStream.ConfigureAwait(false)) + if (user is not null && isInNetwork) { - await JsonSerializer.SerializeAsync(fileStream, spr).ConfigureAwait(false); + byte[] bytes = new byte[4]; + RandomNumberGenerator.Fill(bytes); + string pin = BitConverter.ToString(bytes); + + SerializablePasswordReset spr = new SerializablePasswordReset + { + ExpirationDate = expireTime, + Pin = pin, + PinFile = pinFile, + UserName = user.Username + }; + + FileStream fileStream = AsyncFile.Create(pinFile); + await using (fileStream.ConfigureAwait(false)) + { + await JsonSerializer.SerializeAsync(fileStream, spr).ConfigureAwait(false); + } } return new ForgotPasswordResult { Action = ForgotPasswordAction.PinCode, PinExpirationDate = expireTime, - PinFile = filePath + PinFile = pinFile }; } diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index d0b41a7f6..b534ccd1b 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -508,23 +508,18 @@ namespace Jellyfin.Server.Implementations.Users public async Task<ForgotPasswordResult> StartForgotPasswordProcess(string enteredUsername, bool isInNetwork) { var user = string.IsNullOrWhiteSpace(enteredUsername) ? null : GetUserByName(enteredUsername); + var passwordResetProvider = GetPasswordResetProvider(user); + + var result = await passwordResetProvider + .StartForgotPasswordProcess(user, enteredUsername, isInNetwork) + .ConfigureAwait(false); if (user is not null && isInNetwork) { - var passwordResetProvider = GetPasswordResetProvider(user); - var result = await passwordResetProvider - .StartForgotPasswordProcess(user, isInNetwork) - .ConfigureAwait(false); - await UpdateUserAsync(user).ConfigureAwait(false); - return result; } - return new ForgotPasswordResult - { - Action = ForgotPasswordAction.InNetworkRequired, - PinFile = string.Empty - }; + return result; } /// <inheritdoc/> @@ -760,8 +755,13 @@ namespace Jellyfin.Server.Implementations.Users return GetAuthenticationProviders(user)[0]; } - private IPasswordResetProvider GetPasswordResetProvider(User user) + private IPasswordResetProvider GetPasswordResetProvider(User? user) { + if (user is null) + { + return _defaultPasswordResetProvider; + } + return GetPasswordResetProviders(user)[0]; } diff --git a/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs b/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs deleted file mode 100644 index 910b5c467..000000000 --- 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/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj index df630922a..14ab114fb 100644 --- a/Jellyfin.Server/Jellyfin.Server.csproj +++ b/Jellyfin.Server/Jellyfin.Server.csproj @@ -78,7 +78,7 @@ <None Update="wwwroot\api-docs\swagger\custom.css"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> - <None Update="wwwroot\api-docs\banner-dark.svg"> + <None Update="wwwroot\api-docs\jellyfin.svg"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> <None Update="ServerSetupApp/index.mstemplate.html"> diff --git a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs index a954d307e..8c8563190 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); + return; + } + } using var userDbConnection = new SqliteConnection($"Filename={Path.Combine(dataPath, "users.db")}"); userDbConnection.Open(); diff --git a/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs index c6699c21d..0de775e03 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs @@ -50,9 +50,28 @@ namespace Jellyfin.Server.Migrations.Routines public void Perform() { var dataPath = _appPaths.DataPath; - using (var connection = new SqliteConnection($"Filename={Path.Combine(dataPath, DbFilename)}")) + var dbFilePath = Path.Combine(dataPath, DbFilename); + + if (!File.Exists(dbFilePath)) + { + _logger.LogWarning("{Path} doesn't exist, nothing to migrate", dbFilePath); + return; + } + + using (var connection = new SqliteConnection($"Filename={dbFilePath}")) { connection.Open(); + + var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='Tokens';"); + foreach (var row in tableQuery) + { + if (row.GetInt32(0) == 0) + { + _logger.LogWarning("Table 'Tokens' doesn't exist in {Path}, nothing to migrate", dbFilePath); + return; + } + } + using var dbContext = _dbProvider.CreateDbContext(); var authenticatedDevices = connection.Query("SELECT * FROM Tokens"); diff --git a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs index 0d9952ce9..ffd06fea0 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs @@ -78,9 +78,27 @@ namespace Jellyfin.Server.Migrations.Routines var displayPrefs = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var customDisplayPrefs = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var dbFilePath = Path.Combine(_paths.DataPath, DbFilename); + + if (!File.Exists(dbFilePath)) + { + _logger.LogWarning("{Path} doesn't exist, nothing to migrate", dbFilePath); + return; + } + using (var connection = new SqliteConnection($"Filename={dbFilePath}")) { connection.Open(); + + var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='userdisplaypreferences';"); + foreach (var row in tableQuery) + { + if (row.GetInt32(0) == 0) + { + _logger.LogWarning("Table 'userdisplaypreferences' doesn't exist in {Path}, nothing to migrate", dbFilePath); + return; + } + } + using var dbContext = _provider.CreateDbContext(); var results = connection.Query("SELECT * FROM userdisplaypreferences"); diff --git a/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs b/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs index c199ee4d6..aa5530926 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/MigrateLibraryDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs index b90da9f7d..d221d1853 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs @@ -383,8 +383,6 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine }); } - baseItemIds.Clear(); - foreach (var item in peopleCache) { operation.JellyfinDbContext.Peoples.Add(item.Value.Person); diff --git a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs index e5584fb94..8c3361ee1 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); + return; + } + } + 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 8b394dd7a..fbf9c1637 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 dc7fa5eb3..93f71fdc6 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 c04f75d2e..4340969a3 100644 --- a/Jellyfin.Server/ServerSetupApp/SetupServer.cs +++ b/Jellyfin.Server/ServerSetupApp/SetupServer.cs @@ -251,6 +251,7 @@ public sealed class SetupServer : IDisposable { "retryValue", retryAfterValue }, { "version", typeof(Emby.Server.Implementations.ApplicationHost).Assembly.GetName().Version! }, { "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 e5db259cf..890a77619 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 aa8f6dd1c..5032b2aec 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/Jellyfin.Server/wwwroot/api-docs/banner-dark.svg b/Jellyfin.Server/wwwroot/api-docs/banner-dark.svg deleted file mode 100644 index b62b7545c..000000000 --- a/Jellyfin.Server/wwwroot/api-docs/banner-dark.svg +++ /dev/null @@ -1,34 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!-- ***** BEGIN LICENSE BLOCK ***** - - Part of the Jellyfin project (https://jellyfin.media) - - - - All copyright belongs to the Jellyfin contributors; a full list can - - be found in the file CONTRIBUTORS.md - - - - This work is licensed under the Creative Commons Attribution-ShareAlike 4.0 International License. - - To view a copy of this license, visit http://creativecommons.org/licenses/by-sa/4.0/. -- ***** END LICENSE BLOCK ***** --> -<svg id="banner-dark" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1536 512"> - <defs> - <linearGradient id="linear-gradient" x1="110.25" y1="213.3" x2="496.14" y2="436.09" gradientUnits="userSpaceOnUse"> - <stop offset="0" stop-color="#aa5cc3"/> - <stop offset="1" stop-color="#00a4dc"/> - </linearGradient> - </defs> - <title>banner-dark</title> - <g id="banner-dark"> - <g id="banner-dark-icon"> - <path id="inner-shape" d="M261.42,201.62c-20.44,0-86.24,119.29-76.2,139.43s142.48,19.92,152.4,0S281.86,201.63,261.42,201.62Z" fill="url(#linear-gradient)"/> - <path id="outer-shape" d="M261.42,23.3C199.83,23.3,1.57,382.73,31.8,443.43s429.34,60,459.24,0S323,23.3,261.42,23.3ZM411.9,390.76c-19.59,39.33-281.08,39.77-300.9,0S221.1,115.48,261.45,115.48,431.49,351.42,411.9,390.76Z" fill="url(#linear-gradient)"/> - </g> - <g id="jellyfin-light-outlines" style="isolation:isolate" transform="translate(43.8)"> - <path d="M556.64,350.75a67,67,0,0,1-22.87-27.47,8.91,8.91,0,0,1-1.49-4.75,7.42,7.42,0,0,1,2.83-5.94,9.25,9.25,0,0,1,6.09-2.38c3.16,0,5.94,1.69,8.31,5.05a48.09,48.09,0,0,0,16.34,20.34,40.59,40.59,0,0,0,24,7.58q20.51,0,33.27-12.62t12.77-33.12V159a8.44,8.44,0,0,1,2.67-6.39,9.56,9.56,0,0,1,6.83-2.52,9,9,0,0,1,6.68,2.52,8.7,8.7,0,0,1,2.53,6.39v138.4a64.7,64.7,0,0,1-8.32,32.67,59,59,0,0,1-23,22.72Q608.62,361,589.9,361A57.21,57.21,0,0,1,556.64,350.75Z" fill="#fff"/> - <path d="M831.66,279.47a8.77,8.77,0,0,1-6.24,2.53H713.16q0,17.82,7.27,31.92a54.91,54.91,0,0,0,20.79,22.28q13.51,8.18,31.93,8.17a54,54,0,0,0,25.54-5.94,52.7,52.7,0,0,0,18.12-15.15,10,10,0,0,1,6.24-2.67,8.14,8.14,0,0,1,7.72,7.72,8.81,8.81,0,0,1-3,6.24,74.7,74.7,0,0,1-23.91,19A65.56,65.56,0,0,1,773.45,361q-22.87,0-40.4-9.8a69.51,69.51,0,0,1-27.32-27.48q-9.79-17.66-9.8-40.83,0-24.36,9.65-42.62t25.69-27.92a65.2,65.2,0,0,1,34.16-9.65A70,70,0,0,1,798.84,211a65.78,65.78,0,0,1,25.39,24.36q9.81,16,10.1,38A8.07,8.07,0,0,1,831.66,279.47ZM733.5,231.8Q718.8,243.68,714.64,266H815.92v-2.38A46.91,46.91,0,0,0,807,240.27a48.47,48.47,0,0,0-18.56-15.15,54,54,0,0,0-23-5.2Q748.2,219.92,733.5,231.8Z" fill="#fff"/> - <path d="M888.24,355.5a8.92,8.92,0,0,1-15.3-6.38v-202a8.91,8.91,0,1,1,17.82,0v202A8.65,8.65,0,0,1,888.24,355.5Z" fill="#fff"/> - <path d="M956.55,355.5a8.92,8.92,0,0,1-15.3-6.38v-202a8.91,8.91,0,1,1,17.82,0v202A8.65,8.65,0,0,1,956.55,355.5Z" fill="#fff"/> - <path d="M1122.86,206.11a8.7,8.7,0,0,1,2.53,6.39v131q0,23.44-9.21,40.09a61.58,61.58,0,0,1-25.54,25.25q-16.34,8.61-36.83,8.61a96.73,96.73,0,0,1-23.31-2.68,61.72,61.72,0,0,1-18-7.12q-6.24-3.87-6.24-8.62a17.94,17.94,0,0,1,.6-3,8.06,8.06,0,0,1,3-4.45,7.49,7.49,0,0,1,4.45-1.49,7.91,7.91,0,0,1,3.56.89q19,10.39,36.24,10.4,24.65,0,39.06-15.44t14.4-42.18V333.38a54.37,54.37,0,0,1-21.38,20,62.55,62.55,0,0,1-30.3,7.58q-25.83,0-39.2-15.45t-13.37-41.87V212.5a8.91,8.91,0,1,1,17.82,0V301q0,21.39,9.36,32.38t29.25,11a48,48,0,0,0,23.32-6.09,49.88,49.88,0,0,0,17.82-16,37.44,37.44,0,0,0,6.68-21.24V212.5a9,9,0,0,1,15.29-6.39Z" fill="#fff"/> - <path d="M1210.18,161.41q-5.21,6.24-5.2,17.23v30.59h33.27a8.19,8.19,0,0,1,5.79,2.38,8.26,8.26,0,0,1,0,11.88,8.22,8.22,0,0,1-5.79,2.37H1205V349.12a8.91,8.91,0,1,1-17.82,0V225.86h-21.68a7.83,7.83,0,0,1-5.94-2.52,8.21,8.21,0,0,1-2.37-5.79,8,8,0,0,1,2.37-6.09,8.33,8.33,0,0,1,5.94-2.23h21.68V178.64q0-18.7,10.84-29t29-10.24a46.1,46.1,0,0,1,15.45,2.52q7.13,2.53,7.12,8.17a8.07,8.07,0,0,1-2.37,5.94,7.37,7.37,0,0,1-5.35,2.37,18.81,18.81,0,0,1-6.53-1.48,42,42,0,0,0-10.4-1.78Q1215.37,155.18,1210.18,161.41ZM1276,180.87c-2.19-1.88-3.27-4.61-3.27-8.17v-3q0-5.34,3.41-8.17t9.36-2.82q11.88,0,11.88,11v3c0,3.56-1,6.29-3.12,8.17s-5.1,2.82-9.06,2.82S1278.14,182.75,1276,180.87Zm15.59,174.63a8.92,8.92,0,0,1-15.3-6.38V212.5a8.91,8.91,0,1,1,17.82,0V349.12A8.65,8.65,0,0,1,1291.56,355.5Z" fill="#fff"/> - <path d="M1452.53,218.88q12.92,16.2,12.92,42.92v87.32a8.4,8.4,0,0,1-2.67,6.38,8.8,8.8,0,0,1-6.24,2.53,8.64,8.64,0,0,1-8.91-8.91V262.69q0-19.31-9.65-31.33t-29.85-12a53.28,53.28,0,0,0-42.77,21.83,36.24,36.24,0,0,0-7.13,21.53v86.43a8.91,8.91,0,1,1-17.82,0V216.06a8.91,8.91,0,1,1,17.82,0V232.4q8-12.77,23-21.24A61.84,61.84,0,0,1,1412,202.7Q1439.61,202.7,1452.53,218.88Z" fill="#fff"/> - </g> - </g> -</svg>
\ No newline at end of file diff --git a/Jellyfin.Server/wwwroot/api-docs/jellyfin.svg b/Jellyfin.Server/wwwroot/api-docs/jellyfin.svg new file mode 100644 index 000000000..692530319 --- /dev/null +++ b/Jellyfin.Server/wwwroot/api-docs/jellyfin.svg @@ -0,0 +1,26 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="251" height="72" fill="none" viewBox="0 0 251 72"> + <g clip-path="url(#a)"> + <path fill="url(#b)" + d="M24.212 49.158C22.66 46.042 32.838 27.588 36 27.588c3.167.002 13.323 18.488 11.788 21.57-1.534 3.082-22.025 3.116-23.576 0" /> + <path fill="url(#c)" fill-rule="evenodd" + d="M.482 64.995C-4.195 55.605 26.477 0 36 0c9.533 0 40.153 55.713 35.527 64.995s-66.368 9.39-71.045 0m12.254-8.148c3.064 6.152 43.518 6.084 46.548 0 3.03-6.086-17.032-42.586-23.275-42.586S9.671 50.694 12.736 56.847" + clip-rule="evenodd" /> + <path fill="#fff" + d="M225.22 56c-.28 0-.42 0-.527-.055a.5.5 0 0 1-.219-.218c-.054-.107-.054-.247-.054-.527V26.8c0-.28 0-.42.054-.527a.5.5 0 0 1 .219-.219c.107-.054.247-.054.527-.054h5.183c.28 0 .42 0 .527.054a.5.5 0 0 1 .218.219c.055.107.055.247.055.527v2.895a7.9 7.9 0 0 1 3.419-3.254q2.261-1.103 5.074-1.103 3.308 0 5.845 1.434a10.1 10.1 0 0 1 4.026 4.026q1.434 2.536 1.434 5.9V55.2c0 .28 0 .42-.055.527a.5.5 0 0 1-.218.218c-.107.055-.247.055-.527.055h-5.625c-.28 0-.42 0-.527-.055a.5.5 0 0 1-.218-.218c-.055-.107-.055-.247-.055-.527V38.408q0-2.978-1.709-4.688-1.654-1.764-4.357-1.764-2.702 0-4.412 1.764-1.654 1.766-1.654 4.688V55.2c0 .28 0 .42-.054.527a.5.5 0 0 1-.219.218c-.107.055-.247.055-.527.055zm-11.54-33.363c-.28 0-.42 0-.527-.055a.5.5 0 0 1-.218-.218c-.055-.107-.055-.247-.055-.527v-6.121c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.219c.107-.054.247-.054.527-.054h5.624c.28 0 .42 0 .527.054a.5.5 0 0 1 .219.219c.054.107.054.247.054.527v6.12c0 .28 0 .42-.054.528a.5.5 0 0 1-.219.218c-.107.055-.247.055-.527.055zm0 33.363c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.218-.219c-.055-.107-.055-.247-.055-.527V26.8c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.218c.107-.055.247-.055.527-.055h5.624c.28 0 .42 0 .527.055a.5.5 0 0 1 .219.218c.054.107.054.247.054.527v28.4c0 .28 0 .42-.054.527a.5.5 0 0 1-.219.219c-.107.054-.247.054-.527.054zm-16.712-.054c.107.054.247.054.527.054h5.625c.28 0 .42 0 .526-.054a.5.5 0 0 0 .219-.219c.055-.107.055-.247.055-.527V32.452h5.872c.28 0 .42 0 .527-.054a.5.5 0 0 0 .219-.219c.054-.107.054-.247.054-.527V26.8c0-.28 0-.42-.054-.527a.5.5 0 0 0-.219-.218c-.107-.055-.247-.055-.527-.055h-5.872v-.992q0-2.261 1.323-3.31 1.379-1.102 3.75-1.102.454 0 .939.044c.345.031.518.047.634-.004a.48.48 0 0 0 .241-.22c.061-.111.061-.274.061-.6V15.39c0-.304 0-.457-.061-.589a.7.7 0 0 0-.248-.284c-.122-.078-.261-.097-.537-.136a14.5 14.5 0 0 0-1.966-.126q-5.184 0-8.273 2.812t-3.088 7.942V26H186.53c-.3 0-.451 0-.58.05a.75.75 0 0 0-.296.205c-.091.104-.143.244-.248.526l-7.43 19.9-7.483-19.903c-.105-.28-.158-.42-.249-.524a.75.75 0 0 0-.296-.205c-.129-.049-.279-.049-.578-.049h-5.769c-.394 0-.591 0-.717.083a.5.5 0 0 0-.213.314c-.031.147.041.33.186.697L174.281 56l-.661 1.6q-.883 1.874-2.041 3.033-1.103 1.158-3.584 1.158-.883 0-1.875-.166a13 13 0 0 1-.73-.1c-.389-.066-.584-.099-.709-.053a.47.47 0 0 0-.26.22c-.066.116-.066.298-.066.663v4.329c0 .243 0 .365.045.481a.7.7 0 0 0 .189.266c.095.081.194.116.392.185q.684.24 1.47.351 1.158.22 2.371.22 4.246 0 7.059-2.426 2.867-2.37 4.577-6.728l10.517-26.58h5.72V55.2c0 .28 0 .42.055.527a.5.5 0 0 0 .218.219M154.363 56c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.219-.219c-.054-.107-.054-.247-.054-.527V15.054c0-.28 0-.42.054-.527a.5.5 0 0 1 .219-.219c.107-.054.247-.054.527-.054h5.624c.28 0 .42 0 .527.054a.5.5 0 0 1 .218.219c.055.107.055.247.055.527V55.2c0 .28 0 .42-.055.527a.5.5 0 0 1-.218.219c-.107.054-.247.054-.527.054zm-11.621 0c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.219-.219c-.054-.107-.054-.247-.054-.527V15.054c0-.28 0-.42.054-.527a.5.5 0 0 1 .219-.219c.107-.054.247-.054.527-.054h5.624c.28 0 .42 0 .527.054a.5.5 0 0 1 .219.219c.054.107.054.247.054.527V55.2c0 .28 0 .42-.054.527a.5.5 0 0 1-.219.219c-.107.054-.247.054-.527.054zm-18.132.662q-4.632-.001-8.107-2.096a14.6 14.6 0 0 1-5.404-5.68q-1.93-3.585-1.93-7.942 0-4.522 1.93-7.996 1.985-3.53 5.349-5.57 3.42-2.04 7.61-2.04 4.688 0 7.942 2.04 3.253 1.986 4.963 5.294 1.71 3.309 1.709 7.335 0 .828-.11 1.654-.031.45-.12.841c-.037.165-.055.247-.115.33a.55.55 0 0 1-.208.168c-.095.04-.194.04-.393.04h-21.057q.33 3.309 2.537 5.294 2.205 1.986 5.459 1.985 2.482 0 4.191-1.047a8.2 8.2 0 0 0 2.206-1.986c.241-.316.362-.474.484-.542a.6.6 0 0 1 .352-.083c.139.006.296.083.608.236l4.269 2.094c.239.118.359.176.431.275a.52.52 0 0 1 .098.298c0 .122-.058.231-.172.45q-1.432 2.742-4.526 4.607-3.419 2.04-7.996 2.04m-.552-25.368q-2.702 0-4.687 1.654-1.93 1.6-2.537 4.577h14.118q-.22-2.757-2.151-4.466-1.875-1.765-4.743-1.765M90.801 56c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.218-.218C90 55.62 90 55.48 90 55.2v-5.294c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.218c.107-.055.247-.055.527-.055h1.572q2.646 0 4.19-1.489 1.6-1.545 1.6-4.08V15.715c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.219c.107-.054.247-.054.527-.054h5.956c.28 0 .42 0 .527.054a.5.5 0 0 1 .218.219c.055.107.055.247.055.527v27.546q0 3.804-1.655 6.672-1.599 2.868-4.632 4.467-2.979 1.6-7.06 1.6z" /> + </g> + <defs> + <linearGradient id="b" x1="12" x2="71.999" y1="30.001" y2="63.002" + gradientUnits="userSpaceOnUse"> + <stop stop-color="#aa5cc3" /> + <stop offset="1" stop-color="#00a4dc" /> + </linearGradient> + <linearGradient id="c" x1="12" x2="71.999" y1="29.999" y2="63.001" + gradientUnits="userSpaceOnUse"> + <stop stop-color="#aa5cc3" /> + <stop offset="1" stop-color="#00a4dc" /> + </linearGradient> + <clipPath id="a"> + <path fill="#fff" d="M0 0h251v72H0z" /> + </clipPath> + </defs> +</svg>
\ No newline at end of file diff --git a/Jellyfin.Server/wwwroot/api-docs/swagger/custom.css b/Jellyfin.Server/wwwroot/api-docs/swagger/custom.css index acb59888e..c14ad6021 100644 --- a/Jellyfin.Server/wwwroot/api-docs/swagger/custom.css +++ b/Jellyfin.Server/wwwroot/api-docs/swagger/custom.css @@ -4,12 +4,14 @@ } .topbar-wrapper .link:after { - content: url(../banner-dark.svg); + content: ''; display: block; - -moz-box-sizing: border-box; + background-image: url(../jellyfin.svg); + background-position: center; + background-repeat: no-repeat; + background-size: contain; box-sizing: border-box; - max-width: 100%; - max-height: 100%; - width: 150px; + width: 220px; + height: 40px; } /* end logo */ diff --git a/MediaBrowser.Common/Configuration/IApplicationPaths.cs b/MediaBrowser.Common/Configuration/IApplicationPaths.cs index 6d1a72b04..3a6197490 100644 --- a/MediaBrowser.Common/Configuration/IApplicationPaths.cs +++ b/MediaBrowser.Common/Configuration/IApplicationPaths.cs @@ -103,11 +103,11 @@ namespace MediaBrowser.Common.Configuration void MakeSanityCheckOrThrow(); /// <summary> - /// Checks and creates the given path and adds it with a marker file if non existant. + /// Checks and creates the given path and adds it with a marker file if non existent. /// </summary> /// <param name="path">The path to check.</param> /// <param name="markerName">The common marker file name.</param> - /// <param name="recursive">Check for other settings paths recursivly.</param> + /// <param name="recursive">Check for other settings paths recursively.</param> void CreateAndCheckMarker(string path, string markerName, bool recursive = false); } } diff --git a/MediaBrowser.Controller/Authentication/IPasswordResetProvider.cs b/MediaBrowser.Controller/Authentication/IPasswordResetProvider.cs index 592ce9955..36cd5c5d1 100644 --- a/MediaBrowser.Controller/Authentication/IPasswordResetProvider.cs +++ b/MediaBrowser.Controller/Authentication/IPasswordResetProvider.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -15,11 +13,12 @@ namespace MediaBrowser.Controller.Authentication bool IsEnabled { get; } - Task<ForgotPasswordResult> StartForgotPasswordProcess(User user, bool isInNetwork); + Task<ForgotPasswordResult> StartForgotPasswordProcess(User? user, string enteredUsername, bool isInNetwork); Task<PinRedeemResult> RedeemPasswordResetPin(string pin); } +#nullable disable public class PasswordPinCreationResult { public string PinFile { get; set; } diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 4989f0f3f..3c46d53e5 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -24,6 +24,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaSegments; using MediaBrowser.Controller.Persistence; @@ -1127,6 +1128,15 @@ namespace MediaBrowser.Controller.Entities var protocol = item.PathProtocol; + // Resolve the item path so everywhere we use the media source it will always point to + // the correct path even if symlinks are in use. Calling ResolveLinkTarget on a non-link + // path will return null, so it's safe to check for all paths. + var itemPath = item.Path; + if (protocol is MediaProtocol.File && FileSystemHelper.ResolveLinkTarget(itemPath, returnFinalTarget: true) is { Exists: true } linkInfo) + { + itemPath = linkInfo.FullName; + } + var info = new MediaSourceInfo { Id = item.Id.ToString("N", CultureInfo.InvariantCulture), @@ -1134,7 +1144,7 @@ namespace MediaBrowser.Controller.Entities MediaStreams = MediaSourceManager.GetMediaStreams(item.Id), MediaAttachments = MediaSourceManager.GetMediaAttachments(item.Id), Name = GetMediaSourceName(item), - Path = enablePathSubstitution ? GetMappedPath(item, item.Path, protocol) : item.Path, + Path = enablePathSubstitution ? GetMappedPath(item, itemPath, protocol) : itemPath, RunTimeTicks = item.RunTimeTicks, Container = item.Container, Size = item.Size, diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index e9a383690..151b957fe 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -715,14 +715,21 @@ 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); - result.TotalRecordCount = totalCount; - return result; + return PostFilterAndSort(items, query); } if (this is not UserRootFolder @@ -980,25 +987,19 @@ 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); - result.TotalRecordCount = totalItemCount; - return result; + return PostFilterAndSort(items, query); } protected QueryResult<BaseItem> PostFilterAndSort(IEnumerable<BaseItem> items, InternalItemsQuery query) @@ -1034,7 +1035,15 @@ namespace MediaBrowser.Controller.Entities items = UserViewBuilder.FilterForAdjacency(items.ToList(), query.AdjacentTo.Value); } - return UserViewBuilder.SortAndPage(items, null, query, LibraryManager); + var filteredItems = items as IReadOnlyList<BaseItem> ?? items.ToList(); + var result = UserViewBuilder.SortAndPage(filteredItems, null, query, LibraryManager); + + if (query.EnableTotalRecordCount) + { + result.TotalRecordCount = filteredItems.Count; + } + + return result; } private static IEnumerable<BaseItem> CollapseBoxSetItemsIfNeeded( @@ -1047,12 +1056,49 @@ namespace MediaBrowser.Controller.Entities { ArgumentNullException.ThrowIfNull(items); - if (CollapseBoxSetItems(query, queryParent, user, configurationManager)) + if (!CollapseBoxSetItems(query, queryParent, user, configurationManager)) + { + return items; + } + + var config = configurationManager.Configuration; + + bool collapseMovies = config.EnableGroupingMoviesIntoCollections; + bool collapseSeries = config.EnableGroupingShowsIntoCollections; + + if (user is null || (collapseMovies && collapseSeries)) + { + return collectionManager.CollapseItemsWithinBoxSets(items, user); + } + + if (!collapseMovies && !collapseSeries) + { + return items; + } + + var collapsibleItems = new List<BaseItem>(); + var remainingItems = new List<BaseItem>(); + + foreach (var item in items) + { + if ((collapseMovies && item is Movie) || (collapseSeries && item is Series)) + { + collapsibleItems.Add(item); + } + else + { + remainingItems.Add(item); + } + } + + if (collapsibleItems.Count == 0) { - items = collectionManager.CollapseItemsWithinBoxSets(items, user); + return remainingItems; } - return items; + var collapsedItems = collectionManager.CollapseItemsWithinBoxSets(collapsibleItems, user); + + return collapsedItems.Concat(remainingItems); } private static bool CollapseBoxSetItems( @@ -1083,24 +1129,26 @@ namespace MediaBrowser.Controller.Entities } var param = query.CollapseBoxSetItems; - - if (!param.HasValue) + if (param.HasValue) { - if (user is not null && query.IncludeItemTypes.Any(type => - (type == BaseItemKind.Movie && !configurationManager.Configuration.EnableGroupingMoviesIntoCollections) || - (type == BaseItemKind.Series && !configurationManager.Configuration.EnableGroupingShowsIntoCollections))) - { - return false; - } + return param.Value && AllowBoxSetCollapsing(query); + } - if (query.IncludeItemTypes.Length == 0 - || query.IncludeItemTypes.Any(type => type == BaseItemKind.Movie || type == BaseItemKind.Series)) - { - param = true; - } + var config = configurationManager.Configuration; + + bool queryHasMovies = query.IncludeItemTypes.Length == 0 || query.IncludeItemTypes.Contains(BaseItemKind.Movie); + bool queryHasSeries = query.IncludeItemTypes.Length == 0 || query.IncludeItemTypes.Contains(BaseItemKind.Series); + + bool collapseMovies = config.EnableGroupingMoviesIntoCollections; + bool collapseSeries = config.EnableGroupingShowsIntoCollections; + + if (user is not null) + { + bool canCollapse = (queryHasMovies && collapseMovies) || (queryHasSeries && collapseSeries); + return canCollapse && AllowBoxSetCollapsing(query); } - return param.HasValue && param.Value && AllowBoxSetCollapsing(query); + return (queryHasMovies || queryHasSeries) && AllowBoxSetCollapsing(query); } private static bool AllowBoxSetCollapsing(InternalItemsQuery request) diff --git a/MediaBrowser.Controller/IO/FileSystemHelper.cs b/MediaBrowser.Controller/IO/FileSystemHelper.cs index 1a33c3aa8..3e390ca42 100644 --- a/MediaBrowser.Controller/IO/FileSystemHelper.cs +++ b/MediaBrowser.Controller/IO/FileSystemHelper.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using MediaBrowser.Model.IO; @@ -61,4 +62,82 @@ public static class FileSystemHelper } } } + + /// <summary> + /// Gets the target of the specified file link. + /// </summary> + /// <remarks> + /// This helper exists because of this upstream runtime issue; https://github.com/dotnet/runtime/issues/92128. + /// </remarks> + /// <param name="linkPath">The path of the file link.</param> + /// <param name="returnFinalTarget">true to follow links to the final target; false to return the immediate next link.</param> + /// <returns> + /// A <see cref="FileInfo"/> if the <paramref name="linkPath"/> is a link, regardless of if the target exists; otherwise, <c>null</c>. + /// </returns> + public static FileInfo? ResolveLinkTarget(string linkPath, bool returnFinalTarget = false) + { + // Check if the file exists so the native resolve handler won't throw at us. + if (!File.Exists(linkPath)) + { + return null; + } + + if (!returnFinalTarget) + { + return File.ResolveLinkTarget(linkPath, returnFinalTarget: false) as FileInfo; + } + + if (File.ResolveLinkTarget(linkPath, returnFinalTarget: false) is not FileInfo targetInfo) + { + return null; + } + + if (!targetInfo.Exists) + { + return targetInfo; + } + + var currentPath = targetInfo.FullName; + var visited = new HashSet<string>(StringComparer.Ordinal) { linkPath, currentPath }; + while (File.ResolveLinkTarget(currentPath, returnFinalTarget: false) is FileInfo linkInfo) + { + var targetPath = linkInfo.FullName; + + // If an infinite loop is detected, return the file info for the + // first link in the loop we encountered. + if (!visited.Add(targetPath)) + { + return new FileInfo(targetPath); + } + + targetInfo = linkInfo; + currentPath = targetPath; + + // Exit if the target doesn't exist, so the native resolve handler won't throw at us. + if (!targetInfo.Exists) + { + break; + } + } + + return targetInfo; + } + + /// <summary> + /// Gets the target of the specified file link. + /// </summary> + /// <remarks> + /// This helper exists because of this upstream runtime issue; https://github.com/dotnet/runtime/issues/92128. + /// </remarks> + /// <param name="fileInfo">The file info of the file link.</param> + /// <param name="returnFinalTarget">true to follow links to the final target; false to return the immediate next link.</param> + /// <returns> + /// A <see cref="FileInfo"/> if the <paramref name="fileInfo"/> is a link, regardless of if the target exists; otherwise, <c>null</c>. + /// </returns> + public static FileInfo? ResolveLinkTarget(FileInfo fileInfo, bool returnFinalTarget = false) + { + ArgumentNullException.ThrowIfNull(fileInfo); + + return ResolveLinkTarget(fileInfo.FullName, returnFinalTarget); + } } diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index c81e639a2..a1d891535 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.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index 8350d1613..b7fef842b 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 00a9ae797..eb312029a 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.MediaEncoding/Transcoding/TranscodeManager.cs b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs index 0cda803d6..2fd054f11 100644 --- a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs +++ b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs @@ -396,7 +396,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable ArgumentException.ThrowIfNullOrEmpty(_mediaEncoder.EncoderPath); // If subtitles get burned in fonts may need to be extracted from the media file - if (state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode) + if (state.SubtitleStream is not null && (state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode || state.BaseRequest.AlwaysBurnInSubtitleWhenTranscoding)) { if (state.MediaSource.VideoType == VideoType.Dvd || state.MediaSource.VideoType == VideoType.BluRay) { diff --git a/MediaBrowser.Model/Users/ForgotPasswordAction.cs b/MediaBrowser.Model/Users/ForgotPasswordAction.cs index f198476e3..55907e6c8 100644 --- a/MediaBrowser.Model/Users/ForgotPasswordAction.cs +++ b/MediaBrowser.Model/Users/ForgotPasswordAction.cs @@ -1,11 +1,15 @@ #pragma warning disable CS1591 +using System; + namespace MediaBrowser.Model.Users { public enum ForgotPasswordAction { + [Obsolete("Returning different actions represents a security concern.")] ContactAdmin = 0, PinCode = 1, + [Obsolete("Returning different actions represents a security concern.")] InNetworkRequired = 2 } } diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index 1d83263c5..f220ec4a1 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -151,7 +151,10 @@ namespace MediaBrowser.Providers.Manager .ConfigureAwait(false); updateType |= beforeSaveResult; - updateType = await SaveInternal(item, refreshOptions, updateType, isFirstRefresh, requiresRefresh, metadataResult, cancellationToken).ConfigureAwait(false); + if (!isFirstRefresh) + { + updateType = await SaveInternal(item, refreshOptions, updateType, isFirstRefresh, requiresRefresh, metadataResult, cancellationToken).ConfigureAwait(false); + } // Next run metadata providers if (refreshOptions.MetadataRefreshMode != MetadataRefreshMode.None) @@ -229,6 +232,11 @@ namespace MediaBrowser.Providers.Manager if (file is not null) { item.DateModified = file.LastWriteTimeUtc; + + if (!file.IsDirectory) + { + item.Size = file.Length; + } } } @@ -312,12 +320,8 @@ namespace MediaBrowser.Providers.Manager { if (EnableUpdateMetadataFromChildren(item, isFullRefresh, updateType)) { - if (isFullRefresh || updateType > ItemUpdateType.None) - { - var children = GetChildrenForMetadataUpdates(item); - - updateType = UpdateMetadataFromChildren(item, children, isFullRefresh, updateType); - } + var children = GetChildrenForMetadataUpdates(item); + updateType = UpdateMetadataFromChildren(item, children, isFullRefresh, updateType); } var presentationUniqueKey = item.CreatePresentationUniqueKey(); @@ -339,7 +343,10 @@ namespace MediaBrowser.Providers.Manager item.DateModified = info.LastWriteTimeUtc; if (ServerConfigurationManager.GetMetadataConfiguration().UseFileCreationTimeForDateAdded) { - item.DateCreated = info.CreationTimeUtc; + if (info.CreationTimeUtc > DateTime.MinValue) + { + item.DateCreated = info.CreationTimeUtc; + } } if (item is Video video) @@ -357,16 +364,24 @@ namespace MediaBrowser.Providers.Manager protected virtual bool EnableUpdateMetadataFromChildren(TItemType item, bool isFullRefresh, ItemUpdateType currentUpdateType) { - if (isFullRefresh || currentUpdateType > ItemUpdateType.None) + if (item is Folder folder) { - if (EnableUpdatingPremiereDateFromChildren || EnableUpdatingGenresFromChildren || EnableUpdatingStudiosFromChildren || EnableUpdatingOfficialRatingFromChildren) + if (!isFullRefresh && currentUpdateType == ItemUpdateType.None) { - return true; + return folder.SupportsDateLastMediaAdded; } - if (item is Folder folder) + if (isFullRefresh || currentUpdateType > ItemUpdateType.None) { - return folder.SupportsDateLastMediaAdded || folder.SupportsCumulativeRunTimeTicks; + if (EnableUpdatingPremiereDateFromChildren || EnableUpdatingGenresFromChildren || EnableUpdatingStudiosFromChildren || EnableUpdatingOfficialRatingFromChildren) + { + return true; + } + + if (folder.SupportsDateLastMediaAdded || folder.SupportsCumulativeRunTimeTicks) + { + return true; + } } } @@ -387,36 +402,42 @@ namespace MediaBrowser.Providers.Manager { var updateType = ItemUpdateType.None; - if (isFullRefresh || currentUpdateType > ItemUpdateType.None) + if (item is Folder folder) { - updateType |= UpdateCumulativeRunTimeTicks(item, children); - updateType |= UpdateDateLastMediaAdded(item, children); - - // don't update user-changeable metadata for locked items - if (item.IsLocked) + if (folder.SupportsDateLastMediaAdded) { - return updateType; + updateType |= UpdateDateLastMediaAdded(item, children); } - if (EnableUpdatingPremiereDateFromChildren) + if ((isFullRefresh || currentUpdateType > ItemUpdateType.None) && folder.SupportsCumulativeRunTimeTicks) { - updateType |= UpdatePremiereDate(item, children); + updateType |= UpdateCumulativeRunTimeTicks(item, children); } + } - if (EnableUpdatingGenresFromChildren) - { - updateType |= UpdateGenres(item, children); - } + if (!(isFullRefresh || currentUpdateType > ItemUpdateType.None) || item.IsLocked) + { + return updateType; + } - if (EnableUpdatingStudiosFromChildren) - { - updateType |= UpdateStudios(item, children); - } + if (EnableUpdatingPremiereDateFromChildren) + { + updateType |= UpdatePremiereDate(item, children); + } - if (EnableUpdatingOfficialRatingFromChildren) - { - updateType |= UpdateOfficialRating(item, children); - } + if (EnableUpdatingGenresFromChildren) + { + updateType |= UpdateGenres(item, children); + } + + if (EnableUpdatingStudiosFromChildren) + { + updateType |= UpdateStudios(item, children); + } + + if (EnableUpdatingOfficialRatingFromChildren) + { + updateType |= UpdateOfficialRating(item, children); } return updateType; diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs index bdb6b93be..bde23e842 100644 --- a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs +++ b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs @@ -520,7 +520,7 @@ namespace MediaBrowser.Providers.MediaInfo { Name = person.Name, Type = person.Type, - Role = person.Role.Trim() + Role = person.Role?.Trim() }); } } diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs index ad9edb031..82c6e3011 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/TV/TmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs index 0953dde1c..e30c555cb 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs @@ -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.XbmcMetadata/Providers/BaseNfoProvider.cs b/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs index c671e7a93..5ac672f10 100644 --- a/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs +++ b/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs @@ -68,12 +68,15 @@ namespace MediaBrowser.XbmcMetadata.Providers { var file = GetXmlFile(new ItemInfo(item), directoryService); - if (file is null) + if (file?.Exists is not true) { return false; } - return file.Exists && _fileSystem.GetLastWriteTimeUtc(file) > item.DateLastSaved; + var fileTime = _fileSystem.GetLastWriteTimeUtc(file); + + // 1 minute tolerance to avoid detecting our own file writes + return (fileTime - item.DateLastSaved) > TimeSpan.FromMinutes(1); } protected abstract void Fetch(MetadataResult<T> result, string path, CancellationToken cancellationToken); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs index 8cb483f49..f386e882e 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs @@ -70,13 +70,14 @@ public static class JellyfinQueryHelperExtensions 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 => - itemValueTypes.Contains(val.itemVal.Type) - && context.BaseItems.Where(itemFilter).Any(e => e.CleanName == val.itemVal.CleanValue) + context.BaseItems.Where(itemFilter).Any(e => e.CleanName == val.itemVal.CleanValue) && val.map.ItemId == item.Id) == EF.Constant(!invert)); } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs index b90a2e056..7bcc7eeca 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs @@ -52,10 +52,14 @@ public class OptimisticLockBehavior : IEntityFrameworkCoreLockingBehavior _logger = logger; _writePolicy = Policy - .HandleInner<Exception>(e => e.Message.Contains("database is locked", StringComparison.InvariantCultureIgnoreCase)) + .HandleInner<Exception>(e => + e.Message.Contains("database is locked", StringComparison.InvariantCultureIgnoreCase) || + e.Message.Contains("database table is locked", StringComparison.InvariantCultureIgnoreCase)) .WaitAndRetry(sleepDurations.Length, backoffProvider, RetryHandle); _writeAsyncPolicy = Policy - .HandleInner<Exception>(e => e.Message.Contains("database is locked", StringComparison.InvariantCultureIgnoreCase)) + .HandleInner<Exception>(e => + e.Message.Contains("database is locked", StringComparison.InvariantCultureIgnoreCase) || + e.Message.Contains("database table is locked", StringComparison.InvariantCultureIgnoreCase)) .WaitAndRetryAsync(sleepDurations.Length, backoffProvider, RetryHandle); void RetryHandle(Exception exception, TimeSpan timespan, int retryNo, Context context) diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs index 2b000b257..da63df8e2 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs @@ -64,6 +64,7 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider sqliteConnectionBuilder.DataSource = Path.Combine(_applicationPaths.DataPath, "jellyfin.db"); sqliteConnectionBuilder.Cache = GetOption(customOptions, "cache", Enum.Parse<SqliteCacheMode>, () => SqliteCacheMode.Default); sqliteConnectionBuilder.Pooling = GetOption(customOptions, "pooling", e => e.Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase), () => true); + sqliteConnectionBuilder.DefaultTimeout = GetOption(customOptions, "command-timeout", int.Parse, () => 30); var connectionString = sqliteConnectionBuilder.ToString(); diff --git a/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs b/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs index a0dafb8f1..cbe97a821 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 4c8ba58d0..4dbe769bf 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,20 @@ 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)] + [InlineData("/media/YouTube/Devyn Johnston/2024-01-24 4070 Ti SUPER in under 7 minutes", "/media/YouTube/Devyn Johnston", null, false)] + [InlineData("/media/YouTube/Devyn Johnston/2025-01-28 5090 vs 2 SFF Cases", "/media/YouTube/Devyn Johnston", null, false)] + [InlineData("/Drive/202401244070", "/Drive", null, false)] + [InlineData("/Drive/Drive.S01.2160p.WEB-DL.DDP5.1.H.265-XXXX", "/Drive", 1, true)] + [InlineData("The Wonder Years/The.Wonder.Years.S04.1080p.PDTV.x264-JCH", "/The Wonder Years", 4, true)] + [InlineData("The Wonder Years/[The.Wonder.Years.S04.1080p.PDTV.x264-JCH]", "/The Wonder Years", 4, true)] + [InlineData("The Wonder Years/The.Wonder.Years [S04][1080p.PDTV.x264-JCH]", "/The Wonder Years", 4, true)] + [InlineData("The Wonder Years/The Wonder Years Season 01 1080p", "/The Wonder Years", 1, true)] + public void GetSeasonNumberFromPathTest(string path, string? parentPath, int? seasonNumber, bool isSeasonDirectory) { var result = SeasonPathParser.Parse(path, parentPath, true, true); diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/OrderMapperTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/OrderMapperTests.cs index caf2b06b7..8ac3e5e31 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/OrderMapperTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/OrderMapperTests.cs @@ -12,7 +12,7 @@ public class OrderMapperTests [Fact] public void ShouldReturnMappedOrderForSortingByPremierDate() { - var orderFunc = OrderMapper.MapOrderByField(ItemSortBy.PremiereDate, new InternalItemsQuery()).Compile(); + var orderFunc = OrderMapper.MapOrderByField(ItemSortBy.PremiereDate, new InternalItemsQuery(), null!).Compile(); var expectedDate = new DateTime(1, 2, 3); var expectedProductionYearDate = new DateTime(4, 1, 1); |
