diff options
158 files changed, 1568 insertions, 1071 deletions
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 029a48f6a..1d65527d9 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "9.0.11", + "version": "10.0.2", "commands": [ "dotnet-ef" ] diff --git a/.editorconfig b/.editorconfig index 313b02563..fa679f120 100644 --- a/.editorconfig +++ b/.editorconfig @@ -379,6 +379,9 @@ dotnet_diagnostic.CA1720.severity = suggestion # disable warning CA1724: Type names should not match namespaces dotnet_diagnostic.CA1724.severity = suggestion +# disable warning CA1873: Avoid potentially expensive logging +dotnet_diagnostic.CA1873.severity = suggestion + # disable warning CA1805: Do not initialize unnecessarily dotnet_diagnostic.CA1805.severity = suggestion @@ -400,6 +403,10 @@ dotnet_diagnostic.CA1861.severity = suggestion # disable warning CA2000: Dispose objects before losing scope dotnet_diagnostic.CA2000.severity = suggestion +# TODO: Reevaluate when false positives are fixed: https://github.com/dotnet/roslyn-analyzers/issues/7699 +# disable warning CA2025: Do not pass 'IDisposable' instances into unawaited tasks +dotnet_diagnostic.CA2025.severity = suggestion + # disable warning CA2253: Named placeholders should not be numeric values dotnet_diagnostic.CA2253.severity = suggestion diff --git a/.github/ISSUE_TEMPLATE/issue report.yml b/.github/ISSUE_TEMPLATE/issue report.yml index a505d4168..9bcff76bd 100644 --- a/.github/ISSUE_TEMPLATE/issue report.yml +++ b/.github/ISSUE_TEMPLATE/issue report.yml @@ -87,6 +87,7 @@ body: label: Jellyfin Server version description: What version of Jellyfin are you using? options: + - 10.11.6 - 10.11.5 - 10.11.4 - 10.11.3 diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index 5ca3edd7b..c7a7785e9 100644 --- a/.github/workflows/ci-codeql-analysis.yml +++ b/.github/workflows/ci-codeql-analysis.yml @@ -20,18 +20,21 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup .NET - uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1 + uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0 with: - dotnet-version: '9.0.x' + dotnet-version: '10.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 + uses: github/codeql-action/init@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11 with: languages: ${{ matrix.language }} queries: +security-extended + - name: Autobuild - uses: github/codeql-action/autobuild@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 + uses: github/codeql-action/autobuild@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11 + - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 + uses: github/codeql-action/analyze@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11 diff --git a/.github/workflows/ci-compat.yml b/.github/workflows/ci-compat.yml index a3c49969c..23a82a1b2 100644 --- a/.github/workflows/ci-compat.yml +++ b/.github/workflows/ci-compat.yml @@ -11,15 +11,15 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} - name: Setup .NET - uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1 + uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0 with: - dotnet-version: '9.0.x' + dotnet-version: '10.0.x' - name: Build run: | @@ -40,16 +40,16 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} fetch-depth: 0 - name: Setup .NET - uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1 + uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0 with: - dotnet-version: '9.0.x' + dotnet-version: '10.0.x' - name: Checkout common ancestor env: @@ -106,7 +106,7 @@ jobs: { echo 'body<<EOF' for file in Jellyfin.Data.dll MediaBrowser.Common.dll MediaBrowser.Controller.dll MediaBrowser.Model.dll Emby.Naming.dll Jellyfin.Extensions.dll Jellyfin.MediaEncoding.Keyframes.dll Jellyfin.Database.Implementations.dll; do - COMPAT_OUTPUT="$( { apicompat --left ./abi-base/${file} --right ./abi-head/${file}; } 2>&1 )" + COMPAT_OUTPUT="$( { apicompat --left ./abi-base/${file} --right ./abi-head/${file}; } 2>&1 || true )" if [ "APICompat ran successfully without finding any breaking changes." != "${COMPAT_OUTPUT}" ]; then printf "\n${file}\n${COMPAT_OUTPUT}\n" fi diff --git a/.github/workflows/ci-openapi.yml b/.github/workflows/ci-openapi.yml index 46af68e58..1bf01bf75 100644 --- a/.github/workflows/ci-openapi.yml +++ b/.github/workflows/ci-openapi.yml @@ -16,23 +16,25 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} + - name: Setup .NET - uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1 + uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0 with: - dotnet-version: '9.0.x' + dotnet-version: '10.0.x' - name: Generate openapi.json run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests" + - name: Upload openapi.json uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: openapi-head retention-days: 14 if-no-files-found: error - path: tests/Jellyfin.Server.Integration.Tests/bin/Release/net9.0/openapi.json + path: tests/Jellyfin.Server.Integration.Tests/bin/Release/net10.0/openapi.json openapi-base: name: OpenAPI - BASE @@ -41,11 +43,12 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} fetch-depth: 0 + - name: Checkout common ancestor env: HEAD_REF: ${{ github.head_ref }} @@ -54,23 +57,25 @@ jobs: git -c protocol.version=2 fetch --prune --progress --no-recurse-submodules upstream +refs/heads/*:refs/remotes/upstream/* +refs/tags/*:refs/tags/* ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} origin/$HEAD_REF) git checkout --progress --force $ANCESTOR_REF + - name: Setup .NET - uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1 + uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0 with: - dotnet-version: '9.0.x' + dotnet-version: '10.0.x' - name: Generate openapi.json run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests" + - name: Upload openapi.json uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: openapi-base retention-days: 14 if-no-files-found: error - path: tests/Jellyfin.Server.Integration.Tests/bin/Release/net9.0/openapi.json + path: tests/Jellyfin.Server.Integration.Tests/bin/Release/net10.0/openapi.json openapi-diff: permissions: - pull-requests: write # to create or update comment (peter-evans/create-or-update-comment) + pull-requests: write name: OpenAPI - Difference if: ${{ github.event_name == 'pull_request_target' }} @@ -84,67 +89,23 @@ jobs: with: name: openapi-head path: openapi-head + - name: Download openapi-base uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: openapi-base path: openapi-base - - name: Workaround openapi-diff issue - run: | - sed -i 's/"allOf"/"oneOf"/g' openapi-head/openapi.json - sed -i 's/"allOf"/"oneOf"/g' openapi-base/openapi.json - - name: Calculate OpenAPI difference - uses: docker://openapitools/openapi-diff - continue-on-error: true - with: - args: --fail-on-changed --markdown openapi-changes.md openapi-base/openapi.json openapi-head/openapi.json - - id: read-diff - name: Read openapi-diff output - run: | - # Read and fix markdown - body=$(cat openapi-changes.md) - # Write to workflow summary - echo "$body" >> $GITHUB_STEP_SUMMARY - # Set ApiChanged var - if [ "$body" != '' ]; then - echo "ApiChanged=1" >> "$GITHUB_OUTPUT" - else - echo "ApiChanged=0" >> "$GITHUB_OUTPUT" - fi - # Add header/footer for diff comment - echo '<!--openapi-diff-workflow-comment-->' > openapi-changes-reply.md - echo "<details>" >> openapi-changes-reply.md - echo "<summary>Changes in OpenAPI specification found. Expand to see details.</summary>" >> openapi-changes-reply.md - echo "" >> openapi-changes-reply.md - echo "$body" >> openapi-changes-reply.md - echo "" >> openapi-changes-reply.md - echo "</details>" >> openapi-changes-reply.md - - name: Find difference comment - uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0 - id: find-comment - with: - issue-number: ${{ github.event.pull_request.number }} - direction: last - body-includes: openapi-diff-workflow-comment - - name: Reply or edit difference comment (changed) - uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 - if: ${{ steps.read-diff.outputs.ApiChanged == '1' }} - with: - issue-number: ${{ github.event.pull_request.number }} - comment-id: ${{ steps.find-comment.outputs.comment-id }} - edit-mode: replace - body-path: openapi-changes-reply.md - - name: Edit difference comment (unchanged) - uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 - if: ${{ steps.read-diff.outputs.ApiChanged == '0' && steps.find-comment.outputs.comment-id != '' }} - with: - issue-number: ${{ github.event.pull_request.number }} - comment-id: ${{ steps.find-comment.outputs.comment-id }} - edit-mode: replace - body: | - <!--openapi-diff-workflow-comment--> - No changes to OpenAPI specification found. See history of this comment for previous changes. + - name: Detect OpenAPI changes + id: openapi-diff + uses: jellyfin/openapi-diff-action@9274f6bda9d01ab091942a4a8334baa53692e8a4 # v1.0.0 + with: + old-spec: openapi-base/openapi.json + new-spec: openapi-head/openapi.json + markdown: openapi-changelog.md + add-pr-comment: true + github-token: ${{ secrets.GITHUB_TOKEN }} + publish-unstable: name: OpenAPI - Publish Unstable Spec @@ -178,7 +139,6 @@ jobs: username: "${{ secrets.REPO_USER }}" key: "${{ secrets.REPO_KEY }}" debug: false - script_stop: false script: | if ! test -d /run/workflows; then sudo mkdir -p /run/workflows @@ -240,7 +200,6 @@ jobs: username: "${{ secrets.REPO_USER }}" key: "${{ secrets.REPO_KEY }}" debug: false - script_stop: false script: | if ! test -d /run/workflows; then sudo mkdir -p /run/workflows diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index f70243221..5cb13d694 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -9,7 +9,7 @@ on: pull_request: env: - SDK_VERSION: "9.0.x" + SDK_VERSION: "10.0.x" jobs: run-tests: @@ -20,9 +20,9 @@ jobs: runs-on: "${{ matrix.os }}" steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1 + - uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0 with: dotnet-version: ${{ env.SDK_VERSION }} diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml index 0d3e09d1a..2c4efcc8c 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: token: ${{ secrets.JF_BOT_TOKEN }} fetch-depth: 0 @@ -40,16 +40,19 @@ jobs: runs-on: ubuntu-latest steps: - name: pull in script - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: jellyfin/jellyfin-triage-script + - name: install python - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.14' cache: 'pip' + - name: install python packages run: pip install -r rename/requirements.txt + - name: run rename script run: python3 rename.py working-directory: ./rename diff --git a/.github/workflows/issue-template-check.yml b/.github/workflows/issue-template-check.yml index 8be48b5c3..dcd1fb7cf 100644 --- a/.github/workflows/issue-template-check.yml +++ b/.github/workflows/issue-template-check.yml @@ -10,16 +10,19 @@ jobs: issues: write steps: - name: pull in script - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: jellyfin/jellyfin-triage-script + - name: install python - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.14' cache: 'pip' + - name: install python packages run: pip install -r main-repo-triage/requirements.txt + - name: check and comment issue working-directory: ./main-repo-triage run: python3 single_issue_gha.py diff --git a/.github/workflows/project-automation.yml b/.github/workflows/project-automation.yml index d62f655b3..7b29d3c81 100644 --- a/.github/workflows/project-automation.yml +++ b/.github/workflows/project-automation.yml @@ -21,6 +21,7 @@ jobs: with: project: Current Release action: delete + column: In progress repo-token: ${{ secrets.JF_BOT_TOKEN }} - name: Add to 'Release Next' project diff --git a/.github/workflows/release-bump-version.yaml b/.github/workflows/release-bump-version.yaml index d39d2cb9c..4c6b6b8e7 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ env.TAG_BRANCH }} @@ -66,7 +66,7 @@ jobs: NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }} steps: - name: Checkout Repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ env.TAG_BRANCH }} diff --git a/.vscode/launch.json b/.vscode/launch.json index d97d8de84..681f068b9 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,7 +6,7 @@ "type": "coreclr", "request": "launch", "preLaunchTask": "build", - "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net9.0/jellyfin.dll", + "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net10.0/jellyfin.dll", "args": [], "cwd": "${workspaceFolder}/Jellyfin.Server", "console": "internalConsole", @@ -22,7 +22,7 @@ "type": "coreclr", "request": "launch", "preLaunchTask": "build", - "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net9.0/jellyfin.dll", + "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net10.0/jellyfin.dll", "args": ["--nowebclient"], "cwd": "${workspaceFolder}/Jellyfin.Server", "console": "internalConsole", @@ -34,7 +34,7 @@ "type": "coreclr", "request": "launch", "preLaunchTask": "build", - "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net9.0/jellyfin.dll", + "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net10.0/jellyfin.dll", "args": ["--nowebclient", "--ffmpeg", "/usr/lib/jellyfin-ffmpeg/ffmpeg"], "cwd": "${workspaceFolder}/Jellyfin.Server", "console": "internalConsole", diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 4406413da..1770db60b 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -117,6 +117,7 @@ - [sachk](https://github.com/sachk) - [sammyrc34](https://github.com/sammyrc34) - [samuel9554](https://github.com/samuel9554) + - [SapientGuardian](https://github.com/SapientGuardian) - [scheidleon](https://github.com/scheidleon) - [sebPomme](https://github.com/sebPomme) - [SegiH](https://github.com/SegiH) @@ -205,7 +206,11 @@ - [theshoeshiner](https://github.com/theshoeshiner) - [TokerX](https://github.com/TokerX) - [GeneMarks](https://github.com/GeneMarks) + - [Kirill Nikiforov](https://github.com/allmazz) - [bjorntp](https://github.com/bjorntp) + - [martenumberto](https://github.com/martenumberto) + - [ZeusCraft10](https://github.com/ZeusCraft10) + - [MarcoCoreDuo](https://github.com/MarcoCoreDuo) # Emby Contributors diff --git a/Directory.Packages.props b/Directory.Packages.props index 564223483..242cc77e6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,7 +4,7 @@ </PropertyGroup> <!-- Run "dotnet list package (dash,dash)outdated" to see the latest versions of each package.--> <ItemGroup Label="Package Dependencies"> - <PackageVersion Include="AsyncKeyedLock" Version="7.1.8" /> + <PackageVersion Include="AsyncKeyedLock" Version="8.0.0" /> <PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.1" /> <PackageVersion Include="AutoFixture.Xunit2" Version="4.18.1" /> <PackageVersion Include="AutoFixture" Version="4.18.1" /> @@ -14,7 +14,7 @@ <PackageVersion Include="BlurHashSharp" Version="1.4.0-pre.1" /> <PackageVersion Include="CommandLineParser" Version="2.9.1" /> <PackageVersion Include="coverlet.collector" Version="6.0.4" /> - <PackageVersion Include="Diacritics" Version="4.0.17" /> + <PackageVersion Include="Diacritics" Version="4.1.4" /> <PackageVersion Include="DiscUtils.Udf" Version="0.16.13" /> <PackageVersion Include="DotNet.Glob" Version="3.1.3" /> <PackageVersion Include="FsCheck.Xunit" Version="3.3.2" /> @@ -25,33 +25,28 @@ <PackageVersion Include="Jellyfin.XmlTv" Version="10.8.0" /> <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.11" /> - <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.11" /> + <PackageVersion Include="MetaBrainz.MusicBrainz" Version="7.0.0" /> + <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.2" /> + <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.2" /> <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.Common" Version="5.0.0" /> + <PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.0.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.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.Data.Sqlite" Version="10.0.2" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.2" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.2" /> + <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.2" /> + <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.2" /> + <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.2" /> + <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.2" /> + <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" /> + <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.2" /> + <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.2" /> + <PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.2" /> + <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.2" /> + <PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.2" /> <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" /> @@ -63,10 +58,10 @@ <PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" /> <PackageVersion Include="prometheus-net" Version="8.2.1" /> <PackageVersion Include="Polly" Version="8.6.5" /> - <PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" /> + <PackageVersion Include="Serilog.AspNetCore" Version="10.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.Settings.Configuration" Version="10.0.0" /> <PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" /> <PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" /> <PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" /> @@ -80,20 +75,16 @@ <PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" /> <PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" /> <PackageVersion Include="Svg.Skia" Version="3.2.1" /> - <PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="6.5.0" /> - <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.11" /> - <PackageVersion Include="System.Text.Json" Version="9.0.11" /> - <PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.11" /> + <PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="6.9.0" /> + <PackageVersion Include="Swashbuckle.AspNetCore" Version="7.3.2" /> + <PackageVersion Include="System.Text.Json" Version="10.0.2" /> <PackageVersion Include="TagLibSharp" Version="2.3.0" /> - <PackageVersion Include="z440.atl.core" Version="7.9.0" /> + <PackageVersion Include="z440.atl.core" Version="7.10.0" /> <PackageVersion Include="TMDbLib" Version="2.3.0" /> <PackageVersion Include="UTF.Unknown" Version="2.6.0" /> <PackageVersion Include="Xunit.Priority" Version="1.1.6" /> <PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" /> - <PackageVersion Include="Xunit.SkippableFact" Version="1.5.23" /> + <PackageVersion Include="Xunit.SkippableFact" Version="1.5.61" /> <PackageVersion Include="xunit" Version="2.9.3" /> </ItemGroup> </Project> diff --git a/Emby.Naming/Emby.Naming.csproj b/Emby.Naming/Emby.Naming.csproj index b84c96116..97b52e42a 100644 --- a/Emby.Naming/Emby.Naming.csproj +++ b/Emby.Naming/Emby.Naming.csproj @@ -6,7 +6,7 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <PublishRepositoryUrl>true</PublishRepositoryUrl> diff --git a/Emby.Naming/Video/VideoListResolver.cs b/Emby.Naming/Video/VideoListResolver.cs index a3134f3f6..4247fea0e 100644 --- a/Emby.Naming/Video/VideoListResolver.cs +++ b/Emby.Naming/Video/VideoListResolver.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Text.RegularExpressions; @@ -136,19 +137,27 @@ namespace Emby.Naming.Video if (videos.Count > 1) { - var groups = videos.GroupBy(x => ResolutionRegex().IsMatch(x.Files[0].FileNameWithoutExtension)).ToList(); + var groups = videos + .Select(x => (filename: x.Files[0].FileNameWithoutExtension.ToString(), value: x)) + .Select(x => (x.filename, resolutionMatch: ResolutionRegex().Match(x.filename), x.value)) + .GroupBy(x => x.resolutionMatch.Success) + .ToList(); + videos.Clear(); + + StringComparer comparer = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering); foreach (var group in groups) { if (group.Key) { videos.InsertRange(0, group - .OrderByDescending(x => ResolutionRegex().Match(x.Files[0].FileNameWithoutExtension.ToString()).Value, new AlphanumericComparator()) - .ThenBy(x => x.Files[0].FileNameWithoutExtension.ToString(), new AlphanumericComparator())); + .OrderByDescending(x => x.resolutionMatch.Value, comparer) + .ThenBy(x => x.filename, comparer) + .Select(x => x.value)); } else { - videos.AddRange(group.OrderBy(x => x.Files[0].FileNameWithoutExtension.ToString(), new AlphanumericComparator())); + videos.AddRange(group.OrderBy(x => x.filename, comparer).Select(x => x.value)); } } } diff --git a/Emby.Photos/Emby.Photos.csproj b/Emby.Photos/Emby.Photos.csproj index 645a74aea..3faeae380 100644 --- a/Emby.Photos/Emby.Photos.csproj +++ b/Emby.Photos/Emby.Photos.csproj @@ -19,7 +19,7 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> diff --git a/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs b/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs index 5380c45d8..0381c4d35 100644 --- a/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs +++ b/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs @@ -39,22 +39,24 @@ namespace Emby.Server.Implementations.Cryptography { if (string.Equals(hash.Id, "PBKDF2", StringComparison.Ordinal)) { + var iterations = GetIterationsParameter(hash); return hash.Hash.SequenceEqual( Rfc2898DeriveBytes.Pbkdf2( password, hash.Salt, - int.Parse(hash.Parameters["iterations"], CultureInfo.InvariantCulture), + iterations, HashAlgorithmName.SHA1, 32)); } if (string.Equals(hash.Id, "PBKDF2-SHA512", StringComparison.Ordinal)) { + var iterations = GetIterationsParameter(hash); return hash.Hash.SequenceEqual( Rfc2898DeriveBytes.Pbkdf2( password, hash.Salt, - int.Parse(hash.Parameters["iterations"], CultureInfo.InvariantCulture), + iterations, HashAlgorithmName.SHA512, DefaultOutputLength)); } @@ -62,6 +64,27 @@ namespace Emby.Server.Implementations.Cryptography throw new NotSupportedException($"Can't verify hash with id: {hash.Id}"); } + /// <summary> + /// Extracts and validates the iterations parameter from a password hash. + /// </summary> + /// <param name="hash">The password hash containing parameters.</param> + /// <returns>The number of iterations.</returns> + /// <exception cref="FormatException">Thrown when iterations parameter is missing or invalid.</exception> + private static int GetIterationsParameter(PasswordHash hash) + { + if (!hash.Parameters.TryGetValue("iterations", out var iterationsStr)) + { + throw new FormatException($"Password hash with id '{hash.Id}' is missing required 'iterations' parameter."); + } + + if (!int.TryParse(iterationsStr, CultureInfo.InvariantCulture, out var iterations)) + { + throw new FormatException($"Password hash with id '{hash.Id}' has invalid 'iterations' parameter: '{iterationsStr}'."); + } + + return iterations; + } + /// <inheritdoc /> public byte[] GenerateSalt() => GenerateSalt(DefaultSaltLength); diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj index 15843730e..f312fb4db 100644 --- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj +++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj @@ -27,7 +27,6 @@ <PackageReference Include="Microsoft.Data.Sqlite" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" /> <PackageReference Include="Microsoft.Extensions.Caching.Memory" /> - <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" /> <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" /> <PackageReference Include="prometheus-net.DotNetRuntime" /> @@ -39,7 +38,7 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> diff --git a/Emby.Server.Implementations/IO/LibraryMonitor.cs b/Emby.Server.Implementations/IO/LibraryMonitor.cs index d87ad729e..7cff2a25b 100644 --- a/Emby.Server.Implementations/IO/LibraryMonitor.cs +++ b/Emby.Server.Implementations/IO/LibraryMonitor.cs @@ -352,6 +352,12 @@ namespace Emby.Server.Implementations.IO return; } + var fileInfo = _fileSystem.GetFileSystemInfo(path); + if (DotIgnoreIgnoreRule.IsIgnored(fileInfo, null)) + { + return; + } + // Ignore certain files, If the parent of an ignored path has a change event, ignore that too foreach (var i in _tempIgnoredPaths.Keys) { diff --git a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs index 273d356a3..a25373326 100644 --- a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs +++ b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs @@ -98,5 +98,11 @@ namespace Emby.Server.Implementations.Images return base.CreateImage(item, itemsWithImages, outputPath, imageType, imageIndex); } + + protected override bool HasChangedByDate(BaseItem item, ItemImageInfo image) + { + var age = DateTime.UtcNow - image.DateModified; + return age.TotalDays > 7; + } } } diff --git a/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs b/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs index 473ff8e1d..ef5d24c70 100644 --- a/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs +++ b/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Text.RegularExpressions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Resolvers; @@ -70,12 +71,55 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule { // If file has content, base ignoring off the content .gitignore-style rules var rules = ignoreFileContent.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return CheckIgnoreRules(path, rules, isDirectory); + } + + /// <summary> + /// Checks whether a path should be ignored based on an array of ignore rules. + /// </summary> + /// <param name="path">The path to check.</param> + /// <param name="rules">The array of ignore rules.</param> + /// <param name="isDirectory">Whether the path is a directory.</param> + /// <returns>True if the path should be ignored.</returns> + internal static bool CheckIgnoreRules(string path, string[] rules, bool isDirectory) + => CheckIgnoreRules(path, rules, isDirectory, IsWindows); + + /// <summary> + /// Checks whether a path should be ignored based on an array of ignore rules. + /// </summary> + /// <param name="path">The path to check.</param> + /// <param name="rules">The array of ignore rules.</param> + /// <param name="isDirectory">Whether the path is a directory.</param> + /// <param name="normalizePath">Whether to normalize backslashes to forward slashes (for Windows paths).</param> + /// <returns>True if the path should be ignored.</returns> + internal static bool CheckIgnoreRules(string path, string[] rules, bool isDirectory, bool normalizePath) + { var ignore = new Ignore.Ignore(); - ignore.Add(rules); + + // Add each rule individually to catch and skip invalid patterns + var validRulesAdded = 0; + foreach (var rule in rules) + { + try + { + ignore.Add(rule); + validRulesAdded++; + } + catch (RegexParseException) + { + // Ignore invalid patterns + } + } + + // If no valid rules were added, fall back to ignoring everything (like an empty .ignore file) + if (validRulesAdded == 0) + { + return true; + } // 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; + var pathToCheck = normalizePath ? path.NormalizePath('/') : path; // Add trailing slash for directories to match "folder/" if (isDirectory) diff --git a/Emby.Server.Implementations/Library/IgnorePatterns.cs b/Emby.Server.Implementations/Library/IgnorePatterns.cs index fe3a1ce61..5fac2f6b0 100644 --- a/Emby.Server.Implementations/Library/IgnorePatterns.cs +++ b/Emby.Server.Implementations/Library/IgnorePatterns.cs @@ -83,6 +83,7 @@ namespace Emby.Server.Implementations.Library // Unix hidden files "**/.*", + "**/.*/**", // Mac - if you ever remove the above. // "**/._*", diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 30c3e89b4..f7f5c387e 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -1058,6 +1058,7 @@ namespace Emby.Server.Implementations.Library { IncludeItemTypes = [BaseItemKind.MusicArtist], Name = name, + UseRawName = true, DtoOptions = options }).Cast<MusicArtist>() .OrderBy(i => i.IsAccessedByName ? 1 : 0) @@ -2201,6 +2202,12 @@ namespace Emby.Server.Implementations.Library public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken) => UpdateItemsAsync([item], parent, updateReason, cancellationToken); + /// <inheritdoc /> + public async Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken) + { + await _itemRepository.ReattachUserDataAsync(item, cancellationToken).ConfigureAwait(false); + } + public async Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason) { if (item.IsFileProtocol) @@ -3194,19 +3201,7 @@ namespace Emby.Server.Implementations.Library var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath; var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName); - var shortcutFilename = Path.GetFileNameWithoutExtension(path); - - var lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension); - - while (File.Exists(lnk)) - { - shortcutFilename += "1"; - lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension); - } - - _fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path)); - - RemoveContentTypeOverrides(path); + CreateShortcut(virtualFolderPath, pathInfo); if (saveLibraryOptions) { @@ -3371,5 +3366,24 @@ namespace Emby.Server.Implementations.Library return item is UserRootFolder || item.IsVisibleStandalone(user); } + + public void CreateShortcut(string virtualFolderPath, MediaPathInfo pathInfo) + { + var path = pathInfo.Path; + var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath; + + var shortcutFilename = Path.GetFileNameWithoutExtension(path); + + var lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension); + + while (File.Exists(lnk)) + { + shortcutFilename += "1"; + lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension); + } + + _fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path)); + RemoveContentTypeOverrides(path); + } } } diff --git a/Emby.Server.Implementations/Localization/Core/ar.json b/Emby.Server.Implementations/Localization/Core/ar.json index 24ed116f3..d09a7884e 100644 --- a/Emby.Server.Implementations/Localization/Core/ar.json +++ b/Emby.Server.Implementations/Localization/Core/ar.json @@ -2,13 +2,13 @@ "Albums": "ألبومات", "AppDeviceValues": "تطبيق: {0}, جهاز: {1}", "Application": "تطبيق", - "Artists": "الفنانون", + "Artists": "فنانون", "AuthenticationSucceededWithUserName": "نجحت عملية التوثيق بـ {0}", "Books": "الكتب", "CameraImageUploadedFrom": "رُفعت صورة الكاميرا الجديدة من {0}", "Channels": "القنوات", "ChapterNameValue": "الفصل {0}", - "Collections": "المجموعات", + "Collections": "مجموعات", "DeviceOfflineWithName": "قُطِع الاتصال ب{0}", "DeviceOnlineWithName": "{0} متصل", "FailedLoginAttemptWithUserName": "محاولة تسجيل الدخول فاشلة من {0}", @@ -16,7 +16,7 @@ "Folders": "المجلدات", "Genres": "التصنيفات", "HeaderAlbumArtists": "فناني الألبوم", - "HeaderContinueWatching": "أكمل المشاهدة", + "HeaderContinueWatching": "متابعة المشاهدة", "HeaderFavoriteAlbums": "الألبومات المفضلة", "HeaderFavoriteArtists": "الفنانون المفضلون", "HeaderFavoriteEpisodes": "الحلقات المفضلة", diff --git a/Emby.Server.Implementations/Localization/Core/be.json b/Emby.Server.Implementations/Localization/Core/be.json index 29847048c..3d598c491 100644 --- a/Emby.Server.Implementations/Localization/Core/be.json +++ b/Emby.Server.Implementations/Localization/Core/be.json @@ -16,7 +16,7 @@ "Collections": "Калекцыі", "Default": "Па змаўчанні", "FailedLoginAttemptWithUserName": "Няўдалая спроба ўваходу з {0}", - "Folders": "Тэчкі", + "Folders": "Папкі", "Favorites": "Абранае", "External": "Знешні", "Genres": "Жанры", @@ -50,7 +50,7 @@ "User": "Карыстальнік", "UserDeletedWithName": "Карыстальнік {0} быў выдалены", "UserDownloadingItemWithValues": "{0} спампоўваецца {1}", - "TaskOptimizeDatabase": "Аптымізаваць базу дадзеных", + "TaskOptimizeDatabase": "Аптымізацыя базы даных", "Artists": "Выканаўцы", "UserOfflineFromDevice": "{0} адлучыўся ад {1}", "UserPolicyUpdatedWithName": "Палітыка карыстальніка абноўлена для {0}", @@ -59,8 +59,8 @@ "TaskCleanLogsDescription": "Выдаляе файлы журналу, якім больш за {0} дзён.", "TaskUpdatePluginsDescription": "Спампоўвае і ўсталёўвае абнаўленні для плагінаў, якія сканфігураваныя на аўтаматычнае абнаўленне.", "TaskRefreshChannelsDescription": "Абнаўляе інфармацыю аб інтэрнэт-канале.", - "TaskDownloadMissingSubtitlesDescription": "Шукае ў інтэрнэце адсутныя субцітры на аснове канфігурацыі метададзеных.", - "TaskOptimizeDatabaseDescription": "Ушчыльняе базу дадзеных і скарачае вольную прастору. Выкананне гэтай задачы пасля сканавання бібліятэкі або ўнясення іншых зменаў, якія прадугледжваюць мадыфікацыю базы дадзеных, можа палепшыць выдайнасць.", + "TaskDownloadMissingSubtitlesDescription": "Шукае ў інтэрнэце адсутныя субцітры на аснове канфігурацыі метаданых.", + "TaskOptimizeDatabaseDescription": "Сціскае базу даных і вызваляе вольную прастору. Выкананне гэтай задачы пасля сканіравання бібліятэкі або іншых змяненняў, якія мадыфікуюць базу даных, можа палепшыць прадукцыйнасць.", "TaskKeyframeExtractor": "Экстрактар ключавых кадраў", "TasksApplicationCategory": "Праграма", "AppDeviceValues": "Праграма: {0}, Прылада: {1}", @@ -95,7 +95,7 @@ "ServerNameNeedsToBeRestarted": "{0} патрабуе перазапуску", "Shows": "Шоу", "StartupEmbyServerIsLoading": "Jellyfin Server загружаецца. Калі ласка, паўтарыце спробу крыху пазней.", - "SubtitleDownloadFailureFromForItem": "Не атрымалася спампаваць субтытры з {0} для {1}", + "SubtitleDownloadFailureFromForItem": "Субцітры для {1} не ўдалося спампаваць з {0}", "TvShows": "Тэлепраграма", "Undefined": "Нявызначана", "UserLockedOutWithName": "Карыстальнік {0} быў заблакіраваны", @@ -104,7 +104,7 @@ "UserStartedPlayingItemWithValues": "{0} прайграваецца {1} на {2}", "UserStoppedPlayingItemWithValues": "{0} скончыў прайграванне {1} на {2}", "ValueHasBeenAddedToLibrary": "{0} быў дададзены ў вашу медыятэку", - "ValueSpecialEpisodeName": "Спецэпізод - {0}", + "ValueSpecialEpisodeName": "Спецвыпуск - {0}", "VersionNumber": "Версія {0}", "TasksMaintenanceCategory": "Абслугоўванне", "TasksLibraryCategory": "Бібліятэка", @@ -114,7 +114,7 @@ "TaskCleanCacheDescription": "Выдаляе файлы кэша, якія больш не патрэбныя сістэме.", "TaskRefreshChapterImages": "Вынуць выявы раздзелаў", "TaskRefreshLibrary": "Сканаваць бібліятэку", - "TaskRefreshLibraryDescription": "Скануе вашу медыятэку на наяўнасць новых файлаў і абнаўляе метададзеныя.", + "TaskRefreshLibraryDescription": "Сканіруе вашу медыятэку на наяўнасць новых файлаў і абнаўляе метаданыя.", "TaskCleanLogs": "Ачысціць журнал", "TaskRefreshPeople": "Абнавіць выканаўцаў", "TaskRefreshPeopleDescription": "Абнаўленне метаданых для акцёраў і рэжысёраў у вашай медыятэцы.", @@ -136,6 +136,6 @@ "TaskDownloadMissingLyricsDescription": "Спампоўвае тэксты для песняў", "TaskExtractMediaSegments": "Сканіраванне медыя-сегмента", "TaskMoveTrickplayImages": "Перанесці месцазнаходжанне выявы Trickplay", - "CleanupUserDataTask": "Задача па ачыстцы дадзеных карыстальніка", - "CleanupUserDataTaskDescription": "Ачысьціць усе дадзеныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён." + "CleanupUserDataTask": "Задача па ачыстцы даных карыстальніка", + "CleanupUserDataTaskDescription": "Ачышчае ўсе даныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён." } diff --git a/Emby.Server.Implementations/Localization/Core/cy.json b/Emby.Server.Implementations/Localization/Core/cy.json index 794a8e4ce..d9ebd13f0 100644 --- a/Emby.Server.Implementations/Localization/Core/cy.json +++ b/Emby.Server.Implementations/Localization/Core/cy.json @@ -8,7 +8,7 @@ "CameraImageUploadedFrom": "Mae delwedd camera newydd wedi'i lanlwytho o {0}", "Books": "Llyfrau", "AuthenticationSucceededWithUserName": "{0} wedi’i ddilysu’n llwyddiannus", - "Artists": "Artistiaid", + "Artists": "Crewyr", "AppDeviceValues": "Ap: {0}, Dyfais: {1}", "Albums": "Albwmau", "Genres": "Genres", @@ -67,7 +67,7 @@ "NotificationOptionAudioPlayback": "Dechreuwyd chwarae sain", "MessageServerConfigurationUpdated": "Mae gosodiadau gweinydd wedi'i ddiweddaru", "MessageNamedServerConfigurationUpdatedWithValue": "Mae adran gosodiadau gweinydd {0} wedi'i diweddaru", - "FailedLoginAttemptWithUserName": "Cais mewngofnodi wedi methu gan {0}", + "FailedLoginAttemptWithUserName": "Cais mewngofnodi wedi methu o {0}", "ValueHasBeenAddedToLibrary": "{0} wedi'i hychwanegu at eich llyfrgell gyfryngau", "UserStoppedPlayingItemWithValues": "{0} wedi gorffen chwarae {1} ar {2}", "UserStartedPlayingItemWithValues": "{0} yn chwarae {1} ar {2}", @@ -123,5 +123,14 @@ "TaskRefreshChapterImages": "Echdynnu Lluniau Pennod", "TaskCleanCacheDescription": "Dileu ffeiliau cache nad oes eu hangen ar y system mwyach.", "TaskCleanCache": "Gwaghau Ffolder Cache", - "HearingImpaired": "Nam ar y clyw" + "HearingImpaired": "Nam ar y clyw", + "TaskAudioNormalization": "Gwastatau Sain", + "TaskAudioNormalizationDescription": "Yn sganio ffeiliau am ddata gwastatau sain.", + "TaskRefreshTrickplayImages": "Creuwch lluniau Trickplay", + "TaskRefreshTrickplayImagesDescription": "Creu rhagolygon Trickplay ar gyfer fideos mewn llyfrgelloedd gweithredol.", + "TaskDownloadMissingLyrics": "Lawrlwytho geiriau coll", + "TaskDownloadMissingLyricsDescription": "Lawrlwytho geiriau caneuon", + "TaskCleanCollectionsAndPlaylists": "Glanhau casgliadau a rhestrau chwarae", + "TaskCleanCollectionsAndPlaylistsDescription": "Dileu eitemau o gasgliadau a rhestrau chwarae sydd ddim yn bodoli bellach.", + "TaskExtractMediaSegments": "Sganio Darnau Cyfryngau" } diff --git a/Emby.Server.Implementations/Localization/Core/de.json b/Emby.Server.Implementations/Localization/Core/de.json index 8d86b2da1..0b042c8fe 100644 --- a/Emby.Server.Implementations/Localization/Core/de.json +++ b/Emby.Server.Implementations/Localization/Core/de.json @@ -61,7 +61,7 @@ "NotificationOptionVideoPlayback": "Video wird abgespielt", "NotificationOptionVideoPlaybackStopped": "Videowiedergabe gestoppt", "Photos": "Fotos", - "Playlists": "Playlists", + "Playlists": "Wiedergabelisten", "Plugin": "Plugin", "PluginInstalledWithName": "{0} wurde installiert", "PluginUninstalledWithName": "{0} wurde deinstalliert", @@ -96,20 +96,20 @@ "TaskDownloadMissingSubtitles": "Fehlende Untertitel herunterladen", "TaskRefreshChannelsDescription": "Aktualisiert Internet-Kanal-Informationen.", "TaskRefreshChannels": "Kanäle aktualisieren", - "TaskCleanTranscodeDescription": "Löscht temporäre Videodateien, die älter als 24 Stunden sind.", - "TaskCleanTranscode": "Temporäre Videodateien löschen", + "TaskCleanTranscodeDescription": "Löscht Transkodierungsdateien, die älter als einen Tag sind.", + "TaskCleanTranscode": "Transkodierungsverzeichnis leeren", "TaskUpdatePluginsDescription": "Lädt Updates für Plugins herunter, welche für automatische Updates konfiguriert sind und installiert diese.", "TaskUpdatePlugins": "Plugins aktualisieren", "TaskRefreshPeopleDescription": "Aktualisiert Metadaten für Schauspieler und Regisseure in deinen Bibliotheken.", "TaskRefreshPeople": "Personen aktualisieren", "TaskCleanLogsDescription": "Lösche Log-Dateien, die älter als {0} Tage sind.", - "TaskCleanLogs": "Alte Protokolle löschen", - "TaskRefreshLibraryDescription": "Sucht nach neuen Dateien und aktualisiert Infos zu deinen Medien.", + "TaskCleanLogs": "Protokollverzeichnis leeren", + "TaskRefreshLibraryDescription": "Durchsucht deine Medienbibliothek nach neuen Dateien und aktualisiert Metadaten.", "TaskRefreshLibrary": "Medien-Bibliothek scannen", "TaskRefreshChapterImagesDescription": "Erstellt Vorschaubilder für Videokapitel.", "TaskRefreshChapterImages": "Kapitelvorschauen erstellen", - "TaskCleanCacheDescription": "Entfernt nicht mehr benötigte Cache-Dateien.", - "TaskCleanCache": "Cache leeren", + "TaskCleanCacheDescription": "Löscht Cache-Dateien, die vom System nicht mehr benötigt werden.", + "TaskCleanCache": "Cache-Verzeichnis leeren", "TasksChannelsCategory": "Internet-Kanäle", "TasksApplicationCategory": "Anwendung", "TasksLibraryCategory": "Bibliothek", diff --git a/Emby.Server.Implementations/Localization/Core/et.json b/Emby.Server.Implementations/Localization/Core/et.json index 2e692009b..91a0aa663 100644 --- a/Emby.Server.Implementations/Localization/Core/et.json +++ b/Emby.Server.Implementations/Localization/Core/et.json @@ -72,7 +72,7 @@ "NotificationOptionApplicationUpdateAvailable": "Rakenduse uuendus on saadaval", "NewVersionIsAvailable": "Jellyfin serveri uus versioon on allalaadimiseks saadaval.", "NameSeasonUnknown": "Tundmatu hooaeg", - "NameSeasonNumber": "Hooaeg {0}", + "NameSeasonNumber": "{0}. hooaeg", "NameInstallFailed": "{0} paigaldamine nurjus", "MusicVideos": "Muusikavideod", "Music": "Muusika", diff --git a/Emby.Server.Implementations/Localization/Core/ml.json b/Emby.Server.Implementations/Localization/Core/ml.json index 5c3449381..8c20ded3a 100644 --- a/Emby.Server.Implementations/Localization/Core/ml.json +++ b/Emby.Server.Implementations/Localization/Core/ml.json @@ -2,12 +2,12 @@ "AppDeviceValues": "അപ്ലിക്കേഷൻ: {0}, ഉപകരണം: {1}", "Application": "അപ്ലിക്കേഷൻ", "AuthenticationSucceededWithUserName": "{0} വിജയകരമായി പ്രാമാണീകരിച്ചു", - "CameraImageUploadedFrom": "Camera 0 from എന്നതിൽ നിന്ന് ഒരു പുതിയ ക്യാമറ ചിത്രം അപ്ലോഡുചെയ്തു", + "CameraImageUploadedFrom": "{0} എന്നതിൽ നിന്ന് ഒരു പുതിയ ക്യാമറ ചിത്രം അപ്ലോഡുചെയ്തു", "ChapterNameValue": "അധ്യായം {0}", "DeviceOfflineWithName": "{0} വിച്ഛേദിച്ചു", "DeviceOnlineWithName": "{0} ബന്ധിപ്പിച്ചു", "FailedLoginAttemptWithUserName": "{0}ൽ നിന്നുള്ള പ്രവേശന ശ്രമം പരാജയപ്പെട്ടു", - "Forced": "നിർബന്ധിച്ചു", + "Forced": "നിർബന്ധിതമായി", "HeaderFavoriteAlbums": "പ്രിയപ്പെട്ട ആൽബങ്ങൾ", "HeaderFavoriteArtists": "പ്രിയപ്പെട്ട കലാകാരന്മാർ", "HeaderFavoriteEpisodes": "പ്രിയപ്പെട്ട എപ്പിസോഡുകൾ", @@ -114,7 +114,7 @@ "Artists": "കലാകാരന്മാർ", "Shows": "ഷോകൾ", "Default": "സ്ഥിരസ്ഥിതി", - "Favorites": "പ്രിയങ്കരങ്ങൾ", + "Favorites": "പ്രിയപ്പെട്ടവ", "Books": "പുസ്തകങ്ങൾ", "Genres": "വിഭാഗങ്ങൾ", "Channels": "ചാനലുകൾ", diff --git a/Emby.Server.Implementations/Localization/Core/my.json b/Emby.Server.Implementations/Localization/Core/my.json index 4cb4cdc75..097d0d2fb 100644 --- a/Emby.Server.Implementations/Localization/Core/my.json +++ b/Emby.Server.Implementations/Localization/Core/my.json @@ -126,5 +126,7 @@ "TaskRefreshTrickplayImages": "ထရစ်ခ်ပလေး ပုံများကို ထုတ်မည်", "TaskKeyframeExtractor": "ကီးဖရိန်များကို ထုတ်နုတ်ခြင်း", "TaskCleanCollectionsAndPlaylists": "စုစည်းမှုများနှင့် အစဉ်လိုက်ပြသမှုများကို ရှင်းလင်းမည်", - "HearingImpaired": "အကြားအာရုံ ချို့တဲ့သူ" + "HearingImpaired": "အကြားအာရုံ ချို့တဲ့သူ", + "TaskDownloadMissingLyrics": "ကျန်နေသောသီချင်းစာသားများအား ဒေါင်းလုတ်ဆွဲပါ", + "TaskDownloadMissingLyricsDescription": "သီချင်းများအတွက် သီချင်းစာသား ဒေါင်းလုတ်ဆွဲပါ" } diff --git a/Emby.Server.Implementations/Localization/Core/th.json b/Emby.Server.Implementations/Localization/Core/th.json index 113e4f30f..65ddb55e9 100644 --- a/Emby.Server.Implementations/Localization/Core/th.json +++ b/Emby.Server.Implementations/Localization/Core/th.json @@ -135,5 +135,7 @@ "TaskExtractMediaSegments": "การสแกนส่วนของสื่อมีเดีย", "TaskMoveTrickplayImagesDescription": "ย้ายไฟล์ Trickplay ตามการตั้งค่าของไลบรารี", "TaskExtractMediaSegmentsDescription": "แยกหรือดึงส่วนของสื่อจากปลั๊กอินที่เปิดใช้งาน MediaSegment", - "TaskMoveTrickplayImages": "ย้ายตำแหน่งเก็บภาพตัวอย่าง Trickplay" + "TaskMoveTrickplayImages": "ย้ายตำแหน่งเก็บภาพตัวอย่าง Trickplay", + "CleanupUserDataTask": "ส่วนงานล้างข้อมูลผู้ใช้", + "CleanupUserDataTaskDescription": "ล้างข้อมูลผู้ใช้ทั้งหมด (สถานะการรับชม สถานะรายการโปรด ฯลฯ) จากสื่อที่ไม่ได้ใช้งานแล้วอย่างน้อย 90 วัน" } diff --git a/Emby.Server.Implementations/Localization/Core/tr.json b/Emby.Server.Implementations/Localization/Core/tr.json index 478111049..a07e6864e 100644 --- a/Emby.Server.Implementations/Localization/Core/tr.json +++ b/Emby.Server.Implementations/Localization/Core/tr.json @@ -30,7 +30,7 @@ "ItemAddedWithName": "{0} kütüphaneye eklendi", "ItemRemovedWithName": "{0} kütüphaneden silindi", "LabelIpAddressValue": "IP adresi: {0}", - "LabelRunningTimeValue": "Çalışma süresi: {0}", + "LabelRunningTimeValue": "Oynatma süresi: {0}", "Latest": "En son", "MessageApplicationUpdated": "Jellyfin Sunucusu güncellendi", "MessageApplicationUpdatedTo": "Jellyfin Sunucusu {0} sürümüne güncellendi", @@ -42,7 +42,7 @@ "MusicVideos": "Müzik Videoları", "NameInstallFailed": "{0} kurulumu başarısız", "NameSeasonNumber": "{0}. Sezon", - "NameSeasonUnknown": "Bilinmeyen Sezon", + "NameSeasonUnknown": "Sezon Bilinmiyor", "NewVersionIsAvailable": "Jellyfin Sunucusunun yeni bir sürümü indirmek için hazır.", "NotificationOptionApplicationUpdateAvailable": "Uygulama güncellemesi mevcut", "NotificationOptionApplicationUpdateInstalled": "Uygulama güncellemesi yüklendi", @@ -57,7 +57,7 @@ "NotificationOptionPluginUpdateInstalled": "Eklenti güncellemesi yüklendi", "NotificationOptionServerRestartRequired": "Sunucunun yeniden başlatılması gerekiyor", "NotificationOptionTaskFailed": "Zamanlanmış görev hatası", - "NotificationOptionUserLockedOut": "Kullanıcı kilitlendi", + "NotificationOptionUserLockedOut": "Kullanıcı hesabı kilitlendi", "NotificationOptionVideoPlayback": "Video oynatma başladı", "NotificationOptionVideoPlaybackStopped": "Video oynatma durduruldu", "Photos": "Fotoğraflar", @@ -74,7 +74,7 @@ "Songs": "Şarkılar", "StartupEmbyServerIsLoading": "Jellyfin Sunucusu yükleniyor. Lütfen kısa süre sonra tekrar deneyin.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", - "SubtitleDownloadFailureFromForItem": "{1} için alt yazılar {0} sağlayıcısından indirilemedi", + "SubtitleDownloadFailureFromForItem": "{1} için altyazılar {0} sağlayıcısından indirilemedi", "Sync": "Eşzamanlama", "System": "Sistem", "TvShows": "Diziler", @@ -82,7 +82,7 @@ "UserCreatedWithName": "{0} kullanıcısı oluşturuldu", "UserDeletedWithName": "{0} kullanıcısı silindi", "UserDownloadingItemWithValues": "{0} kullanıcısı {1} medyasını indiriyor", - "UserLockedOutWithName": "{0} adlı kullanıcı kilitlendi", + "UserLockedOutWithName": "{0} adlı kullanıcı hesabı kilitlendi", "UserOfflineFromDevice": "{0} kullanıcısının {1} ile bağlantısı kesildi", "UserOnlineFromDevice": "{0} kullanıcısı {1} ile çevrimiçi", "UserPasswordChangedWithName": "{0} kullanıcısının parolası değiştirildi", @@ -98,8 +98,8 @@ "TasksLibraryCategory": "Kütüphane", "TasksMaintenanceCategory": "Bakım", "TaskRefreshPeopleDescription": "Medya kütüphanenizdeki videoların oyuncu ve yönetmen bilgilerini günceller.", - "TaskDownloadMissingSubtitlesDescription": "Meta veri yapılandırmasına dayalı olarak eksik alt yazılar için internette arama yapar.", - "TaskDownloadMissingSubtitles": "Eksik alt yazıları indir", + "TaskDownloadMissingSubtitlesDescription": "Meta veri yapılandırmasına dayalı olarak eksik altyazılar için internette arama yapar.", + "TaskDownloadMissingSubtitles": "Eksik altyazıları indir", "TaskRefreshChannelsDescription": "Internet kanal bilgilerini yenile.", "TaskRefreshChannels": "Kanalları Yenile", "TaskCleanTranscodeDescription": "Bir günden daha eski kod dönüştürme dosyalarını siler.", @@ -125,15 +125,15 @@ "TaskKeyframeExtractor": "Ana Kare Çıkarıcı", "External": "Harici", "HearingImpaired": "Duyma Engelli", - "TaskRefreshTrickplayImages": "Trickplay Görselleri Oluştur", - "TaskRefreshTrickplayImagesDescription": "Etkin kütüphanelerdeki videolar için trickplay önizlemeleri oluşturur.", + "TaskRefreshTrickplayImages": "Hızlı Önizleme Görsellerini Oluştur", + "TaskRefreshTrickplayImagesDescription": "Etkin kütüphanelerdeki videolar için hızlı önizleme görselleri oluşturur.", "TaskCleanCollectionsAndPlaylistsDescription": "Artık var olmayan koleksiyon ve çalma listelerindeki ögeleri kaldırır.", "TaskCleanCollectionsAndPlaylists": "Koleksiyonları ve çalma listelerini temizleyin", "TaskAudioNormalizationDescription": "Ses normalleştirme verileri için dosyaları tarar.", "TaskAudioNormalization": "Ses Normalleştirme", "TaskExtractMediaSegments": "Medya Segmenti Tarama", - "TaskMoveTrickplayImages": "Trickplay Görsel Konumunu Taşıma", - "TaskMoveTrickplayImagesDescription": "Mevcut trickplay dosyalarını kütüphane ayarlarına göre taşır.", + "TaskMoveTrickplayImages": "Hızlı Önizleme Görsel Konumunu Taşıma", + "TaskMoveTrickplayImagesDescription": "Mevcut hızlı önizleme dosyalarını kütüphane ayarlarına göre taşır.", "TaskDownloadMissingLyrics": "Eksik şarkı sözlerini indir", "TaskDownloadMissingLyricsDescription": "Şarkı sözlerini indirir", "TaskExtractMediaSegmentsDescription": "MediaSegment özelliği etkin olan eklentilerden medya segmentlerini çıkarır veya alır.", diff --git a/Emby.Server.Implementations/Localization/Core/zh-CN.json b/Emby.Server.Implementations/Localization/Core/zh-CN.json index 1bfa4e3c3..b9635105a 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-CN.json +++ b/Emby.Server.Implementations/Localization/Core/zh-CN.json @@ -5,60 +5,60 @@ "Artists": "艺术家", "AuthenticationSucceededWithUserName": "{0} 认证成功", "Books": "书籍", - "CameraImageUploadedFrom": "新的相机图像已从 {0} 上传", + "CameraImageUploadedFrom": "已从 {0} 上传新的相机照片", "Channels": "频道", "ChapterNameValue": "章节 {0}", "Collections": "合集", - "DeviceOfflineWithName": "{0} 已断开", + "DeviceOfflineWithName": "{0} 已断开连接", "DeviceOnlineWithName": "{0} 已连接", - "FailedLoginAttemptWithUserName": "来自 {0} 的登录尝试失败", - "Favorites": "我的最爱", + "FailedLoginAttemptWithUserName": "来自 {0} 的登录失败", + "Favorites": "收藏夹", "Folders": "文件夹", "Genres": "类型", "HeaderAlbumArtists": "专辑艺术家", "HeaderContinueWatching": "继续观看", "HeaderFavoriteAlbums": "收藏的专辑", - "HeaderFavoriteArtists": "最爱的艺术家", - "HeaderFavoriteEpisodes": "最爱的剧集", - "HeaderFavoriteShows": "最爱的节目", - "HeaderFavoriteSongs": "最爱的歌曲", + "HeaderFavoriteArtists": "收藏的艺术家", + "HeaderFavoriteEpisodes": "收藏的剧集", + "HeaderFavoriteShows": "收藏的节目", + "HeaderFavoriteSongs": "收藏的歌曲", "HeaderLiveTV": "电视直播", - "HeaderNextUp": "接下来", + "HeaderNextUp": "接下来播放", "HeaderRecordingGroups": "录制组", "HomeVideos": "家庭视频", "Inherit": "继承", "ItemAddedWithName": "{0} 已添加到媒体库", - "ItemRemovedWithName": "{0} 已从媒体库中移除", + "ItemRemovedWithName": "{0} 已从媒体库移除", "LabelIpAddressValue": "IP 地址:{0}", "LabelRunningTimeValue": "运行时间:{0}", "Latest": "最新", "MessageApplicationUpdated": "Jellyfin 服务器已更新", - "MessageApplicationUpdatedTo": "Jellyfin Server 版本已更新为 {0}", + "MessageApplicationUpdatedTo": "Jellyfin 服务器版本已更新到 {0}", "MessageNamedServerConfigurationUpdatedWithValue": "服务器配置 {0} 部分已更新", "MessageServerConfigurationUpdated": "服务器配置已更新", "MixedContent": "混合内容", "Movies": "电影", "Music": "音乐", - "MusicVideos": "音乐视频", + "MusicVideos": "MV", "NameInstallFailed": "{0} 安装失败", "NameSeasonNumber": "第 {0} 季", "NameSeasonUnknown": "未知季", - "NewVersionIsAvailable": "Jellyfin Server 有新版本可以下载。", + "NewVersionIsAvailable": "Jellyfin 服务器有新版本可供下载。", "NotificationOptionApplicationUpdateAvailable": "有可用的应用程序更新", "NotificationOptionApplicationUpdateInstalled": "应用程序更新已安装", - "NotificationOptionAudioPlayback": "音频开始播放", + "NotificationOptionAudioPlayback": "音频已开始播放", "NotificationOptionAudioPlaybackStopped": "音频播放已停止", - "NotificationOptionCameraImageUploaded": "相机图片已上传", + "NotificationOptionCameraImageUploaded": "相机照片已上传", "NotificationOptionInstallationFailed": "安装失败", "NotificationOptionNewLibraryContent": "已添加新内容", - "NotificationOptionPluginError": "插件失败", + "NotificationOptionPluginError": "插件出错", "NotificationOptionPluginInstalled": "插件已安装", "NotificationOptionPluginUninstalled": "插件已卸载", - "NotificationOptionPluginUpdateInstalled": "插件更新已安装", + "NotificationOptionPluginUpdateInstalled": "插件已更新", "NotificationOptionServerRestartRequired": "服务器需要重启", "NotificationOptionTaskFailed": "计划任务失败", - "NotificationOptionUserLockedOut": "用户已锁定", - "NotificationOptionVideoPlayback": "视频开始播放", + "NotificationOptionUserLockedOut": "用户已被锁定", + "NotificationOptionVideoPlayback": "视频已开始播放", "NotificationOptionVideoPlaybackStopped": "视频播放已停止", "Photos": "照片", "Playlists": "播放列表", @@ -72,23 +72,23 @@ "ServerNameNeedsToBeRestarted": "{0} 需要重新启动", "Shows": "节目", "Songs": "歌曲", - "StartupEmbyServerIsLoading": "Jellyfin 服务器加载中。请稍后再试。", + "StartupEmbyServerIsLoading": "Jellyfin 服务器正在启动,请稍后再试。", "SubtitleDownloadFailureForItem": "为 {0} 下载字幕失败", "SubtitleDownloadFailureFromForItem": "无法从 {0} 下载 {1} 的字幕", "Sync": "同步", "System": "系统", "TvShows": "电视剧", "User": "用户", - "UserCreatedWithName": "用户 {0} 已创建", - "UserDeletedWithName": "用户 {0} 已删除", + "UserCreatedWithName": "已创建用户 {0}", + "UserDeletedWithName": "已删除用户 {0}", "UserDownloadingItemWithValues": "{0} 正在下载 {1}", "UserLockedOutWithName": "用户 {0} 已被锁定", "UserOfflineFromDevice": "{0} 已从 {1} 断开", - "UserOnlineFromDevice": "{0} 在线,来自 {1}", - "UserPasswordChangedWithName": "已为用户 {0} 更改密码", - "UserPolicyUpdatedWithName": "用户协议已经被更新为 {0}", - "UserStartedPlayingItemWithValues": "{0} 已在 {2} 上开始播放 {1}", - "UserStoppedPlayingItemWithValues": "{0} 已在 {2} 上停止播放 {1}", + "UserOnlineFromDevice": "{0} 已在 {1} 上线", + "UserPasswordChangedWithName": "用户 {0} 的密码已更改", + "UserPolicyUpdatedWithName": "用户协议已更新为 {0}", + "UserStartedPlayingItemWithValues": "{0} 在 {2} 上开始播放 {1}", + "UserStoppedPlayingItemWithValues": "{0} 在 {2} 上停止播放 {1}", "ValueHasBeenAddedToLibrary": "{0} 已添加至您的媒体库中", "ValueSpecialEpisodeName": "特典 - {0}", "VersionNumber": "版本 {0}", diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index b4c65ad85..bc80c2b40 100644 --- a/Emby.Server.Implementations/Localization/LocalizationManager.cs +++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs @@ -38,6 +38,7 @@ namespace Emby.Server.Implementations.Localization private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + private readonly ConcurrentDictionary<string, CultureDto?> _cultureCache = new(StringComparer.OrdinalIgnoreCase); private List<CultureDto> _cultures = []; private FrozenDictionary<string, string> _iso6392BtoT = null!; @@ -161,6 +162,7 @@ namespace Emby.Server.Implementations.Localization list.Add(new CultureDto(name, displayname, twoCharName, threeLetterNames)); } + _cultureCache.Clear(); _cultures = list; _iso6392BtoT = iso6392BtoTdict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); } @@ -169,20 +171,31 @@ namespace Emby.Server.Implementations.Localization /// <inheritdoc /> public CultureDto? FindLanguageInfo(string language) { - // TODO language should ideally be a ReadOnlySpan but moq cannot mock ref structs - for (var i = 0; i < _cultures.Count; i++) + if (string.IsNullOrEmpty(language)) { - var culture = _cultures[i]; - if (language.Equals(culture.DisplayName, StringComparison.OrdinalIgnoreCase) - || language.Equals(culture.Name, StringComparison.OrdinalIgnoreCase) - || culture.ThreeLetterISOLanguageNames.Contains(language, StringComparison.OrdinalIgnoreCase) - || language.Equals(culture.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase)) - { - return culture; - } + return null; } - return default; + return _cultureCache.GetOrAdd( + language, + static (lang, cultures) => + { + // TODO language should ideally be a ReadOnlySpan but moq cannot mock ref structs + for (var i = 0; i < cultures.Count; i++) + { + var culture = cultures[i]; + if (lang.Equals(culture.DisplayName, StringComparison.OrdinalIgnoreCase) + || lang.Equals(culture.Name, StringComparison.OrdinalIgnoreCase) + || culture.ThreeLetterISOLanguageNames.Contains(lang, StringComparison.OrdinalIgnoreCase) + || lang.Equals(culture.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase)) + { + return culture; + } + } + + return null; + }, + _cultures); } /// <inheritdoc /> @@ -311,15 +324,19 @@ namespace Emby.Server.Implementations.Localization else { // Fall back to server default language for ratings check - // If it has no ratings, use the US ratings - var ratingsDictionary = GetParentalRatingsDictionary() ?? GetParentalRatingsDictionary("us"); + var ratingsDictionary = GetParentalRatingsDictionary(); if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRatingScore? value)) { return value; } } - // If we don't find anything, check all ratings systems + // If we don't find anything, check all ratings systems, starting with US + if (_allParentalRatings.TryGetValue("us", out var usRatings) && usRatings.TryGetValue(rating, out var usValue)) + { + return usValue; + } + foreach (var dictionary in _allParentalRatings.Values) { if (dictionary.TryGetValue(rating, out var value)) diff --git a/Emby.Server.Implementations/Sorting/StudioComparer.cs b/Emby.Server.Implementations/Sorting/StudioComparer.cs index 0edffb783..6d041cf11 100644 --- a/Emby.Server.Implementations/Sorting/StudioComparer.cs +++ b/Emby.Server.Implementations/Sorting/StudioComparer.cs @@ -1,11 +1,11 @@ #pragma warning disable CS1591 using System; +using System.Globalization; using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Sorting; -using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Sorting { @@ -28,7 +28,7 @@ namespace Emby.Server.Implementations.Sorting ArgumentNullException.ThrowIfNull(x); ArgumentNullException.ThrowIfNull(y); - return AlphanumericComparator.CompareValues(x.Studios.FirstOrDefault(), y.Studios.FirstOrDefault()); + return CultureInfo.InvariantCulture.CompareInfo.Compare(x.Studios.FirstOrDefault(), y.Studios.FirstOrDefault(), CompareOptions.NumericOrdering); } } } diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs index 5ff400160..67b77a112 100644 --- a/Emby.Server.Implementations/Updates/InstallationManager.cs +++ b/Emby.Server.Implementations/Updates/InstallationManager.cs @@ -156,6 +156,11 @@ namespace Emby.Server.Implementations.Updates _logger.LogError(ex, "The URL configured for the plugin repository manifest URL is not valid: {Manifest}", manifest); return Array.Empty<PackageInfo>(); } + catch (NotSupportedException ex) + { + _logger.LogError(ex, "The URL scheme configured for the plugin repository is not supported: {Manifest}", manifest); + return Array.Empty<PackageInfo>(); + } catch (HttpRequestException ex) { _logger.LogError(ex, "An error occurred while accessing the plugin manifest: {Manifest}", manifest); @@ -557,7 +562,7 @@ namespace Emby.Server.Implementations.Updates } stream.Position = 0; - ZipFile.ExtractToDirectory(stream, targetDir, true); + await ZipFile.ExtractToDirectoryAsync(stream, targetDir, true, cancellationToken); // Ensure we create one or populate existing ones with missing data. await _pluginManager.PopulateManifest(package.PackageInfo, package.Version, targetDir, status).ConfigureAwait(false); diff --git a/Jellyfin.Api/Controllers/AudioController.cs b/Jellyfin.Api/Controllers/AudioController.cs index e334e1264..4be79ff5a 100644 --- a/Jellyfin.Api/Controllers/AudioController.cs +++ b/Jellyfin.Api/Controllers/AudioController.cs @@ -50,7 +50,6 @@ public class AudioController : BaseJellyfinApiController /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> @@ -107,7 +106,6 @@ public class AudioController : BaseJellyfinApiController [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, [FromQuery] int? audioSampleRate, [FromQuery] int? maxAudioBitDepth, [FromQuery] int? audioBitRate, @@ -159,7 +157,6 @@ public class AudioController : BaseJellyfinApiController EnableAutoStreamCopy = enableAutoStreamCopy ?? true, AllowAudioStreamCopy = allowAudioStreamCopy ?? true, AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, AudioSampleRate = audioSampleRate, MaxAudioChannels = maxAudioChannels, AudioBitRate = audioBitRate, @@ -217,7 +214,6 @@ public class AudioController : BaseJellyfinApiController /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> @@ -274,7 +270,6 @@ public class AudioController : BaseJellyfinApiController [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, [FromQuery] int? audioSampleRate, [FromQuery] int? maxAudioBitDepth, [FromQuery] int? audioBitRate, @@ -326,7 +321,6 @@ public class AudioController : BaseJellyfinApiController EnableAutoStreamCopy = enableAutoStreamCopy ?? true, AllowAudioStreamCopy = allowAudioStreamCopy ?? true, AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, AudioSampleRate = audioSampleRate, MaxAudioChannels = maxAudioChannels, AudioBitRate = audioBitRate, diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index fe6f855b5..15b04051f 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -122,7 +122,6 @@ public class DynamicHlsController : BaseJellyfinApiController /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> @@ -182,7 +181,6 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, [FromQuery] int? audioSampleRate, [FromQuery] int? maxAudioBitDepth, [FromQuery] int? audioBitRate, @@ -238,7 +236,6 @@ public class DynamicHlsController : BaseJellyfinApiController EnableAutoStreamCopy = enableAutoStreamCopy ?? true, AllowAudioStreamCopy = allowAudioStreamCopy ?? true, AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, AudioSampleRate = audioSampleRate, MaxAudioChannels = maxAudioChannels, AudioBitRate = audioBitRate, @@ -364,7 +361,6 @@ public class DynamicHlsController : BaseJellyfinApiController /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> @@ -425,7 +421,6 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, [FromQuery] int? audioSampleRate, [FromQuery] int? maxAudioBitDepth, [FromQuery] int? audioBitRate, @@ -481,7 +476,6 @@ public class DynamicHlsController : BaseJellyfinApiController EnableAutoStreamCopy = enableAutoStreamCopy ?? true, AllowAudioStreamCopy = allowAudioStreamCopy ?? true, AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, AudioSampleRate = audioSampleRate, MaxAudioChannels = maxAudioChannels, AudioBitRate = audioBitRate, @@ -543,7 +537,6 @@ public class DynamicHlsController : BaseJellyfinApiController /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param> @@ -601,7 +594,6 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, [FromQuery] int? audioSampleRate, [FromQuery] int? maxAudioBitDepth, [FromQuery] int? maxStreamingBitrate, @@ -654,7 +646,6 @@ public class DynamicHlsController : BaseJellyfinApiController EnableAutoStreamCopy = enableAutoStreamCopy ?? true, AllowAudioStreamCopy = allowAudioStreamCopy ?? true, AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, AudioSampleRate = audioSampleRate, MaxAudioChannels = maxAudioChannels, AudioBitRate = audioBitRate ?? maxStreamingBitrate, @@ -713,7 +704,6 @@ public class DynamicHlsController : BaseJellyfinApiController /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> @@ -771,7 +761,6 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, [FromQuery] int? audioSampleRate, [FromQuery] int? maxAudioBitDepth, [FromQuery] int? audioBitRate, @@ -826,7 +815,6 @@ public class DynamicHlsController : BaseJellyfinApiController EnableAutoStreamCopy = enableAutoStreamCopy ?? true, AllowAudioStreamCopy = allowAudioStreamCopy ?? true, AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, AudioSampleRate = audioSampleRate, MaxAudioChannels = maxAudioChannels, AudioBitRate = audioBitRate, @@ -887,7 +875,6 @@ public class DynamicHlsController : BaseJellyfinApiController /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param> @@ -943,7 +930,6 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, [FromQuery] int? audioSampleRate, [FromQuery] int? maxAudioBitDepth, [FromQuery] int? maxStreamingBitrate, @@ -996,7 +982,6 @@ public class DynamicHlsController : BaseJellyfinApiController EnableAutoStreamCopy = enableAutoStreamCopy ?? true, AllowAudioStreamCopy = allowAudioStreamCopy ?? true, AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, AudioSampleRate = audioSampleRate, MaxAudioChannels = maxAudioChannels, AudioBitRate = audioBitRate ?? maxStreamingBitrate, @@ -1060,7 +1045,6 @@ public class DynamicHlsController : BaseJellyfinApiController /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> @@ -1124,7 +1108,6 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, [FromQuery] int? audioSampleRate, [FromQuery] int? maxAudioBitDepth, [FromQuery] int? audioBitRate, @@ -1181,7 +1164,6 @@ public class DynamicHlsController : BaseJellyfinApiController EnableAutoStreamCopy = enableAutoStreamCopy ?? true, AllowAudioStreamCopy = allowAudioStreamCopy ?? true, AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, AudioSampleRate = audioSampleRate, MaxAudioChannels = maxAudioChannels, AudioBitRate = audioBitRate, @@ -1247,7 +1229,6 @@ public class DynamicHlsController : BaseJellyfinApiController /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param> @@ -1309,7 +1290,6 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, [FromQuery] int? audioSampleRate, [FromQuery] int? maxAudioBitDepth, [FromQuery] int? maxStreamingBitrate, @@ -1364,7 +1344,6 @@ public class DynamicHlsController : BaseJellyfinApiController EnableAutoStreamCopy = enableAutoStreamCopy ?? true, AllowAudioStreamCopy = allowAudioStreamCopy ?? true, AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, AudioSampleRate = audioSampleRate, MaxAudioChannels = maxAudioChannels, AudioBitRate = audioBitRate ?? maxStreamingBitrate, @@ -1586,16 +1565,6 @@ public class DynamicHlsController : BaseJellyfinApiController var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec); - if (state.BaseRequest.BreakOnNonKeyFrames) - { - // FIXME: this is actually a workaround, as ideally it really should be the client which decides whether non-keyframe - // breakpoints are supported; but current implementation always uses "ffmpeg input seeking" which is liable - // to produce a missing part of video stream before first keyframe is encountered, which may lead to - // awkward cases like a few starting HLS segments having no video whatsoever, which breaks hls.js - _logger.LogInformation("Current HLS implementation doesn't support non-keyframe breaks but one is requested, ignoring that request"); - state.BaseRequest.BreakOnNonKeyFrames = false; - } - var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty; var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); @@ -1746,11 +1715,6 @@ public class DynamicHlsController : BaseJellyfinApiController var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); var copyArgs = "-codec:a:0 copy" + bitStreamArgs + strictArgs; - if (EncodingHelper.IsCopyCodec(videoCodec) && state.EnableBreakOnNonKeyFrames(videoCodec)) - { - return copyArgs + " -copypriorss:a:0 0"; - } - return copyArgs; } @@ -1839,8 +1803,9 @@ public class DynamicHlsController : BaseJellyfinApiController { if (isActualOutputVideoCodecHevc) { - // Prefer dvh1 to dvhe - args += " -tag:v:0 dvh1 -strict -2"; + // Use hvc1 for 8.4. This is what Dolby uses for its official sample streams. Tagging with dvh1 would break some players with strict tag checking like Apple Safari. + var codecTag = state.VideoStream.VideoRangeType == VideoRangeType.DOVIWithHLG ? "hvc1" : "dvh1"; + args += $" -tag:v:0 {codecTag} -strict -2"; } else if (isActualOutputVideoCodecAv1) { diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs index e1d9b6bba..605d2aeec 100644 --- a/Jellyfin.Api/Controllers/ItemUpdateController.cs +++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs @@ -180,11 +180,14 @@ public class ItemUpdateController : BaseJellyfinApiController info.ContentTypeOptions = GetContentTypeOptions(true).ToArray(); info.ContentType = configuredContentType; - if (inheritedContentType is null || inheritedContentType == CollectionType.tvshows) + if (inheritedContentType is null + || inheritedContentType == CollectionType.tvshows + || inheritedContentType == CollectionType.movies) { info.ContentTypeOptions = info.ContentTypeOptions .Where(i => string.IsNullOrWhiteSpace(i.Value) - || string.Equals(i.Value, "TvShows", StringComparison.OrdinalIgnoreCase)) + || string.Equals(i.Value, "TvShows", StringComparison.OrdinalIgnoreCase) + || string.Equals(i.Value, "Movies", StringComparison.OrdinalIgnoreCase)) .ToArray(); } } @@ -418,7 +421,7 @@ public class ItemUpdateController : BaseJellyfinApiController { if (item is IHasAlbumArtist hasAlbumArtists) { - hasAlbumArtists.AlbumArtists = Array.ConvertAll(request.AlbumArtists, i => i.Name); + hasAlbumArtists.AlbumArtists = Array.ConvertAll(request.AlbumArtists, i => i.Name.Trim()); } } @@ -426,7 +429,7 @@ public class ItemUpdateController : BaseJellyfinApiController { if (item is IHasArtist hasArtists) { - hasArtists.Artists = Array.ConvertAll(request.ArtistItems, i => i.Name); + hasArtists.Artists = Array.ConvertAll(request.ArtistItems, i => i.Name.Trim()); } } diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index 4e898c00e..558e1c6c8 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -23,6 +23,7 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Activity; @@ -700,7 +701,18 @@ public class LibraryController : BaseJellyfinApiController // Quotes are valid in linux. They'll possibly cause issues here. var filename = Path.GetFileName(item.Path)?.Replace("\"", string.Empty, StringComparison.Ordinal); - return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path), filename, true); + var filePath = item.Path; + if (item.IsFileProtocol) + { + // PhysicalFile does not work well with symlinks at the moment. + var resolved = FileSystemHelper.ResolveLinkTarget(filePath, returnFinalTarget: true); + if (resolved is not null && resolved.Exists) + { + filePath = resolved.FullName; + } + } + + return PhysicalFile(filePath, MimeTypes.GetMimeType(filePath), filename, true); } /// <summary> diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs index 2a885662b..117811429 100644 --- a/Jellyfin.Api/Controllers/LibraryStructureController.cs +++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs @@ -342,6 +342,17 @@ public class LibraryStructureController : BaseJellyfinApiController return NotFound(); } + LibraryOptions options = item.GetLibraryOptions(); + foreach (var mediaPath in request.LibraryOptions!.PathInfos) + { + if (options.PathInfos.Any(i => i.Path == mediaPath.Path)) + { + continue; + } + + _libraryManager.CreateShortcut(item.Path, mediaPath); + } + item.UpdateLibraryOptions(request.LibraryOptions); return NoContent(); } diff --git a/Jellyfin.Api/Controllers/TrickplayController.cs b/Jellyfin.Api/Controllers/TrickplayController.cs index 2cf66144c..c9f8b3676 100644 --- a/Jellyfin.Api/Controllers/TrickplayController.cs +++ b/Jellyfin.Api/Controllers/TrickplayController.cs @@ -86,7 +86,7 @@ public class TrickplayController : BaseJellyfinApiController [FromRoute, Required] int index, [FromQuery] Guid? mediaSourceId) { - var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); + var item = _libraryManager.GetItemById<BaseItem>(mediaSourceId ?? itemId, User.GetUserId()); if (item is null) { return NotFound(); diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs index fd6334703..b1a91ae70 100644 --- a/Jellyfin.Api/Controllers/UniversalAudioController.cs +++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs @@ -83,7 +83,6 @@ public class UniversalAudioController : BaseJellyfinApiController /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> /// <param name="enableRemoteMedia">Optional. Whether to enable remote media.</param> /// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> /// <param name="enableRedirection">Whether to enable redirection. Defaults to true.</param> /// <response code="200">Audio stream returned.</response> /// <response code="302">Redirected to remote audio stream.</response> @@ -114,7 +113,6 @@ public class UniversalAudioController : BaseJellyfinApiController [FromQuery] int? maxAudioBitDepth, [FromQuery] bool? enableRemoteMedia, [FromQuery] bool enableAudioVbrEncoding = true, - [FromQuery] bool breakOnNonKeyFrames = false, [FromQuery] bool enableRedirection = true) { userId = RequestHelpers.GetUserId(User, userId); @@ -127,7 +125,7 @@ public class UniversalAudioController : BaseJellyfinApiController return NotFound(); } - var deviceProfile = GetDeviceProfile(container, transcodingContainer, audioCodec, transcodingProtocol, breakOnNonKeyFrames, transcodingAudioChannels, maxAudioSampleRate, maxAudioBitDepth, maxAudioChannels); + var deviceProfile = GetDeviceProfile(container, transcodingContainer, audioCodec, transcodingProtocol, transcodingAudioChannels, maxAudioSampleRate, maxAudioBitDepth, maxAudioChannels); _logger.LogInformation("GetPostedPlaybackInfo profile: {@Profile}", deviceProfile); @@ -208,7 +206,6 @@ public class UniversalAudioController : BaseJellyfinApiController EnableAutoStreamCopy = true, AllowAudioStreamCopy = true, AllowVideoStreamCopy = true, - BreakOnNonKeyFrames = breakOnNonKeyFrames, AudioSampleRate = maxAudioSampleRate, MaxAudioChannels = maxAudioChannels, MaxAudioBitDepth = maxAudioBitDepth, @@ -242,7 +239,6 @@ public class UniversalAudioController : BaseJellyfinApiController EnableAutoStreamCopy = true, AllowAudioStreamCopy = true, AllowVideoStreamCopy = true, - BreakOnNonKeyFrames = breakOnNonKeyFrames, AudioSampleRate = maxAudioSampleRate, MaxAudioChannels = maxAudioChannels, AudioBitRate = isStatic ? null : (audioBitRate ?? maxStreamingBitrate), @@ -263,7 +259,6 @@ public class UniversalAudioController : BaseJellyfinApiController string? transcodingContainer, string? audioCodec, MediaStreamProtocol? transcodingProtocol, - bool? breakOnNonKeyFrames, int? transcodingAudioChannels, int? maxAudioSampleRate, int? maxAudioBitDepth, @@ -298,7 +293,6 @@ public class UniversalAudioController : BaseJellyfinApiController Container = transcodingContainer ?? "mp3", AudioCodec = audioCodec ?? "mp3", Protocol = transcodingProtocol ?? MediaStreamProtocol.http, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, MaxAudioChannels = transcodingAudioChannels?.ToString(CultureInfo.InvariantCulture) } }; diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs index e7c6f23ce..ccf8e9063 100644 --- a/Jellyfin.Api/Controllers/VideosController.cs +++ b/Jellyfin.Api/Controllers/VideosController.cs @@ -270,7 +270,6 @@ public class VideosController : BaseJellyfinApiController /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> @@ -329,7 +328,6 @@ public class VideosController : BaseJellyfinApiController [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, [FromQuery] int? audioSampleRate, [FromQuery] int? maxAudioBitDepth, [FromQuery] int? audioBitRate, @@ -386,7 +384,6 @@ public class VideosController : BaseJellyfinApiController EnableAutoStreamCopy = enableAutoStreamCopy ?? true, AllowAudioStreamCopy = allowAudioStreamCopy ?? true, AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, AudioSampleRate = audioSampleRate, MaxAudioChannels = maxAudioChannels, AudioBitRate = audioBitRate, @@ -511,7 +508,6 @@ public class VideosController : BaseJellyfinApiController /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> @@ -570,7 +566,6 @@ public class VideosController : BaseJellyfinApiController [FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, [FromQuery] int? audioSampleRate, [FromQuery] int? maxAudioBitDepth, [FromQuery] int? audioBitRate, @@ -624,7 +619,6 @@ public class VideosController : BaseJellyfinApiController enableAutoStreamCopy, allowVideoStreamCopy, allowAudioStreamCopy, - breakOnNonKeyFrames, audioSampleRate, maxAudioBitDepth, audioBitRate, diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs index a38ad379c..44e1c6d5a 100644 --- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs +++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs @@ -154,7 +154,7 @@ public class DynamicHlsHelper // from universal audio service, need to override the AudioCodec when the actual request differs from original query if (!string.Equals(state.OutputAudioCodec, _httpContextAccessor.HttpContext.Request.Query["AudioCodec"].ToString(), StringComparison.OrdinalIgnoreCase)) { - var newQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(_httpContextAccessor.HttpContext.Request.QueryString.ToString()); + var newQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString); newQuery["AudioCodec"] = state.OutputAudioCodec; queryString = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(string.Empty, newQuery); } @@ -173,10 +173,21 @@ public class DynamicHlsHelper queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons; } - // Main stream - var playlistUrl = isLiveStream ? "live.m3u8" : "main.m3u8"; + // Video rotation metadata is only supported in fMP4 remuxing + if (state.VideoStream is not null + && state.VideoRequest is not null + && (state.VideoStream?.Rotation ?? 0) != 0 + && EncodingHelper.IsCopyCodec(state.OutputVideoCodec) + && !string.IsNullOrWhiteSpace(state.Request.SegmentContainer) + && !string.Equals(state.Request.SegmentContainer, "mp4", StringComparison.OrdinalIgnoreCase)) + { + queryString += "&AllowVideoStreamCopy=false"; + } - playlistUrl += queryString; + // Main stream + var baseUrl = isLiveStream ? "live.m3u8" : "main.m3u8"; + var playlistUrl = baseUrl + queryString; + var playlistQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString); var subtitleStreams = state.MediaSource .MediaStreams @@ -198,37 +209,36 @@ public class DynamicHlsHelper AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User); } - // Video rotation metadata is only supported in fMP4 remuxing - if (state.VideoStream is not null - && state.VideoRequest is not null - && (state.VideoStream?.Rotation ?? 0) != 0 - && EncodingHelper.IsCopyCodec(state.OutputVideoCodec) - && !string.IsNullOrWhiteSpace(state.Request.SegmentContainer) - && !string.Equals(state.Request.SegmentContainer, "mp4", StringComparison.OrdinalIgnoreCase)) - { - playlistUrl += "&AllowVideoStreamCopy=false"; - } - var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup); if (state.VideoStream is not null && state.VideoRequest is not null) { var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); - // Provide SDR HEVC entrance for backward compatibility. - if (encodingOptions.AllowHevcEncoding - && !encodingOptions.AllowAv1Encoding - && EncodingHelper.IsCopyCodec(state.OutputVideoCodec) - && state.VideoStream.VideoRange == VideoRange.HDR - && string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) + // Provide AV1 and HEVC SDR entrances for backward compatibility. + foreach (var sdrVideoCodec in new[] { "av1", "hevc" }) { - var requestedVideoProfiles = state.GetRequestedProfiles("hevc"); - if (requestedVideoProfiles is not null && requestedVideoProfiles.Length > 0) + var isAv1EncodingAllowed = encodingOptions.AllowAv1Encoding + && string.Equals(sdrVideoCodec, "av1", StringComparison.OrdinalIgnoreCase) + && string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase); + var isHevcEncodingAllowed = encodingOptions.AllowHevcEncoding + && string.Equals(sdrVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase) + && string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase); + var isEncodingAllowed = isAv1EncodingAllowed || isHevcEncodingAllowed; + + if (isEncodingAllowed + && EncodingHelper.IsCopyCodec(state.OutputVideoCodec) + && state.VideoStream.VideoRange == VideoRange.HDR) { - // Force HEVC Main Profile and disable video stream copy. - state.OutputVideoCodec = "hevc"; - var sdrVideoUrl = ReplaceProfile(playlistUrl, "hevc", string.Join(',', requestedVideoProfiles), "main"); - sdrVideoUrl += "&AllowVideoStreamCopy=false"; + // Force AV1 and HEVC Main Profile and disable video stream copy. + state.OutputVideoCodec = sdrVideoCodec; + + var sdrPlaylistQuery = playlistQuery; + sdrPlaylistQuery["VideoCodec"] = sdrVideoCodec; + sdrPlaylistQuery[sdrVideoCodec + "-profile"] = "main"; + sdrPlaylistQuery["AllowVideoStreamCopy"] = "false"; + + var sdrVideoUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, sdrPlaylistQuery); // HACK: Use the same bitrate so that the client can choose by other attributes, such as color range. AppendPlaylist(builder, state, sdrVideoUrl, totalBitrate, subtitleGroup); @@ -238,12 +248,30 @@ public class DynamicHlsHelper } } + // Provide H.264 SDR entrance for backward compatibility. + if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) + && state.VideoStream.VideoRange == VideoRange.HDR) + { + // Force H.264 and disable video stream copy. + state.OutputVideoCodec = "h264"; + + var sdrPlaylistQuery = playlistQuery; + sdrPlaylistQuery["VideoCodec"] = "h264"; + sdrPlaylistQuery["AllowVideoStreamCopy"] = "false"; + + var sdrVideoUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, sdrPlaylistQuery); + + // HACK: Use the same bitrate so that the client can choose by other attributes, such as color range. + AppendPlaylist(builder, state, sdrVideoUrl, totalBitrate, subtitleGroup); + + // Restore the video codec + state.OutputVideoCodec = "copy"; + } + // Provide Level 5.0 entrance for backward compatibility. // e.g. Apple A10 chips refuse the master playlist containing SDR HEVC Main Level 5.1 video, // but in fact it is capable of playing videos up to Level 6.1. - if (encodingOptions.AllowHevcEncoding - && !encodingOptions.AllowAv1Encoding - && EncodingHelper.IsCopyCodec(state.OutputVideoCodec) + if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) && state.VideoStream.Level.HasValue && state.VideoStream.Level > 150 && state.VideoStream.VideoRange == VideoRange.SDR @@ -273,12 +301,15 @@ public class DynamicHlsHelper var variation = GetBitrateVariation(totalBitrate); var newBitrate = totalBitrate - variation; - var variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); + var variantQuery = playlistQuery; + variantQuery["VideoBitrate"] = (requestedVideoBitrate - variation).ToString(CultureInfo.InvariantCulture); + var variantUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, variantQuery); AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); variation *= 2; newBitrate = totalBitrate - variation; - variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); + variantQuery["VideoBitrate"] = (requestedVideoBitrate - variation).ToString(CultureInfo.InvariantCulture); + variantUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, variantQuery); AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); } @@ -723,7 +754,9 @@ public class DynamicHlsHelper { if (string.Equals(state.ActualOutputAudioCodec, "aac", StringComparison.OrdinalIgnoreCase)) { - string? profile = state.GetRequestedProfiles("aac").FirstOrDefault(); + string? profile = EncodingHelper.IsCopyCodec(state.OutputAudioCodec) + ? state.AudioStream?.Profile : state.GetRequestedProfiles("aac").FirstOrDefault(); + return HlsCodecStringHelpers.GetAACString(profile); } @@ -757,6 +790,19 @@ public class DynamicHlsHelper return HlsCodecStringHelpers.GetOPUSString(); } + if (string.Equals(state.ActualOutputAudioCodec, "truehd", StringComparison.OrdinalIgnoreCase)) + { + return HlsCodecStringHelpers.GetTRUEHDString(); + } + + if (string.Equals(state.ActualOutputAudioCodec, "dts", StringComparison.OrdinalIgnoreCase)) + { + // lavc only support encoding DTS core profile + string? profile = EncodingHelper.IsCopyCodec(state.OutputAudioCodec) ? state.AudioStream?.Profile : "DTS"; + + return HlsCodecStringHelpers.GetDTSString(profile); + } + return string.Empty; } @@ -863,23 +909,6 @@ public class DynamicHlsHelper return variation; } - private string ReplaceVideoBitrate(string url, int oldValue, int newValue) - { - return url.Replace( - "videobitrate=" + oldValue.ToString(CultureInfo.InvariantCulture), - "videobitrate=" + newValue.ToString(CultureInfo.InvariantCulture), - StringComparison.OrdinalIgnoreCase); - } - - private string ReplaceProfile(string url, string codec, string oldValue, string newValue) - { - string profileStr = codec + "-profile="; - return url.Replace( - profileStr + oldValue, - profileStr + newValue, - StringComparison.OrdinalIgnoreCase); - } - private string ReplacePlaylistCodecsField(StringBuilder playlist, StringBuilder oldValue, StringBuilder newValue) { var oldPlaylist = playlist.ToString(); diff --git a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs index 0efb7f45d..cf42d5f10 100644 --- a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs +++ b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs @@ -42,6 +42,11 @@ public static class HlsCodecStringHelpers public const string OPUS = "Opus"; /// <summary> + /// Codec name for TRUEHD. + /// </summary> + public const string TRUEHD = "mlpa"; + + /// <summary> /// Gets a MP3 codec string. /// </summary> /// <returns>MP3 codec string.</returns> @@ -59,7 +64,7 @@ public static class HlsCodecStringHelpers { StringBuilder result = new StringBuilder("mp4a", 9); - if (string.Equals(profile, "HE", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(profile, "HE-AAC", StringComparison.OrdinalIgnoreCase)) { result.Append(".40.5"); } @@ -118,6 +123,46 @@ public static class HlsCodecStringHelpers } /// <summary> + /// Gets an TRUEHD codec string. + /// </summary> + /// <returns>TRUEHD codec string.</returns> + public static string GetTRUEHDString() + { + return TRUEHD; + } + + /// <summary> + /// Gets an DTS codec string. + /// </summary> + /// <param name="profile">DTS profile.</param> + /// <returns>DTS codec string.</returns> + public static string GetDTSString(string? profile) + { + if (string.Equals(profile, "DTS", StringComparison.OrdinalIgnoreCase) + || string.Equals(profile, "DTS-ES", StringComparison.OrdinalIgnoreCase) + || string.Equals(profile, "DTS 96/24", StringComparison.OrdinalIgnoreCase)) + { + return "dtsc"; + } + + if (string.Equals(profile, "DTS-HD HRA", StringComparison.OrdinalIgnoreCase) + || string.Equals(profile, "DTS-HD MA", StringComparison.OrdinalIgnoreCase) + || string.Equals(profile, "DTS-HD MA + DTS:X", StringComparison.OrdinalIgnoreCase) + || string.Equals(profile, "DTS-HD MA + DTS:X IMAX", StringComparison.OrdinalIgnoreCase)) + { + return "dtsh"; + } + + if (string.Equals(profile, "DTS Express", StringComparison.OrdinalIgnoreCase)) + { + return "dtse"; + } + + // Default to DTS core if profile is invalid + return "dtsc"; + } + + /// <summary> /// Gets a H.264 codec string. /// </summary> /// <param name="profile">H.264 profile.</param> diff --git a/Jellyfin.Api/Helpers/HlsHelpers.cs b/Jellyfin.Api/Helpers/HlsHelpers.cs index cad8d650e..15540338b 100644 --- a/Jellyfin.Api/Helpers/HlsHelpers.cs +++ b/Jellyfin.Api/Helpers/HlsHelpers.cs @@ -45,15 +45,9 @@ public static class HlsHelpers using var reader = new StreamReader(fileStream); var count = 0; - while (!reader.EndOfStream) + string? line; + while ((line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) is not null) { - var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false); - if (line is null) - { - // Nothing currently in buffer. - break; - } - if (line.Contains("#EXTINF:", StringComparison.OrdinalIgnoreCase)) { count++; diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs index 2601fa3be..1e984542e 100644 --- a/Jellyfin.Api/Helpers/StreamingHelpers.cs +++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs @@ -159,6 +159,13 @@ public static class StreamingHelpers string? containerInternal = Path.GetExtension(state.RequestedUrl); + if (string.IsNullOrEmpty(containerInternal) + && (!string.IsNullOrWhiteSpace(streamingRequest.LiveStreamId) + || (mediaSource != null && mediaSource.IsInfiniteStream))) + { + containerInternal = ".ts"; + } + if (!string.IsNullOrEmpty(streamingRequest.Container)) { containerInternal = streamingRequest.Container; @@ -194,7 +201,7 @@ public static class StreamingHelpers state.OutputVideoCodec = state.Request.VideoCodec; state.OutputVideoBitrate = encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec); - encodingHelper.TryStreamCopy(state); + encodingHelper.TryStreamCopy(state, encodingOptions); if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec) && state.OutputVideoBitrate.HasValue) { diff --git a/Jellyfin.Api/Jellyfin.Api.csproj b/Jellyfin.Api/Jellyfin.Api.csproj index 25feaa2d7..3ccf7a746 100644 --- a/Jellyfin.Api/Jellyfin.Api.csproj +++ b/Jellyfin.Api/Jellyfin.Api.csproj @@ -6,7 +6,7 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> diff --git a/Jellyfin.Data/Jellyfin.Data.csproj b/Jellyfin.Data/Jellyfin.Data.csproj index fd852ece9..f7660f35d 100644 --- a/Jellyfin.Data/Jellyfin.Data.csproj +++ b/Jellyfin.Data/Jellyfin.Data.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <PublishRepositoryUrl>true</PublishRepositoryUrl> diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs index 70483c36c..30094a88c 100644 --- a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs +++ b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs @@ -102,7 +102,7 @@ public class BackupService : IBackupService } BackupManifest? manifest; - var manifestStream = zipArchiveEntry.Open(); + var manifestStream = await zipArchiveEntry.OpenAsync().ConfigureAwait(false); await using (manifestStream.ConfigureAwait(false)) { manifest = await JsonSerializer.DeserializeAsync<BackupManifest>(manifestStream, _serializerSettings).ConfigureAwait(false); @@ -160,7 +160,7 @@ public class BackupService : IBackupService } HistoryRow[] historyEntries; - var historyArchive = historyEntry.Open(); + var historyArchive = await historyEntry.OpenAsync().ConfigureAwait(false); await using (historyArchive.ConfigureAwait(false)) { historyEntries = await JsonSerializer.DeserializeAsync<HistoryRow[]>(historyArchive).ConfigureAwait(false) ?? @@ -204,7 +204,7 @@ public class BackupService : IBackupService continue; } - var zipEntryStream = zipEntry.Open(); + var zipEntryStream = await zipEntry.OpenAsync().ConfigureAwait(false); await using (zipEntryStream.ConfigureAwait(false)) { _logger.LogInformation("Restore backup of {Table}", entityType.Type.Name); @@ -329,7 +329,7 @@ public class BackupService : IBackupService _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(); + var zipEntryStream = await zipEntry.OpenAsync().ConfigureAwait(false); await using (zipEntryStream.ConfigureAwait(false)) { var jsonSerializer = new Utf8JsonWriter(zipEntryStream); @@ -366,7 +366,7 @@ public class BackupService : IBackupService 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)))); + await zipArchive.CreateEntryFromFileAsync(item, NormalizePathSeparator(Path.Combine("Config", Path.GetFileName(item)))).ConfigureAwait(false); } void CopyDirectory(string source, string target, string filter = "*") @@ -380,6 +380,7 @@ public class BackupService : IBackupService foreach (var item in Directory.EnumerateFiles(source, filter, SearchOption.AllDirectories)) { + // TODO: @bond make async zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine(target, Path.GetRelativePath(source, item)))); } } @@ -405,7 +406,7 @@ public class BackupService : IBackupService CopyDirectory(Path.Combine(_applicationPaths.InternalMetadataPath), Path.Combine("Data", "metadata")); } - var manifestStream = zipArchive.CreateEntry(ManifestEntryName).Open(); + var manifestStream = await zipArchive.CreateEntry(ManifestEntryName).OpenAsync().ConfigureAwait(false); await using (manifestStream.ConfigureAwait(false)) { await JsonSerializer.SerializeAsync(manifestStream, manifest).ConfigureAwait(false); @@ -505,7 +506,7 @@ public class BackupService : IBackupService return null; } - var manifestStream = manifestEntry.Open(); + var manifestStream = await manifestEntry.OpenAsync().ConfigureAwait(false); await using (manifestStream.ConfigureAwait(false)) { return await JsonSerializer.DeserializeAsync<BackupManifest>(manifestStream, _serializerSettings).ConfigureAwait(false); diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs index 9851d53c4..90aa3a22e 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs @@ -277,7 +277,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.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).Where(dto => dto is not null).ToArray()!; result.StartIndex = filter.StartIndex ?? 0; return result; } @@ -297,7 +297,7 @@ public sealed class BaseItemRepository dbQuery = ApplyQueryPaging(dbQuery, filter); dbQuery = ApplyNavigations(dbQuery, filter); - return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray(); + return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).Where(dto => dto is not null).ToArray()!; } /// <inheritdoc/> @@ -341,7 +341,7 @@ public sealed class BaseItemRepository mainquery = ApplyNavigations(mainquery, filter); - return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray(); + return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).Where(dto => dto is not null).ToArray()!; } /// <inheritdoc /> @@ -410,10 +410,25 @@ public sealed class BaseItemRepository private static IQueryable<BaseItemEntity> ApplyNavigations(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter) { - dbQuery = dbQuery.Include(e => e.TrailerTypes) - .Include(e => e.Provider) - .Include(e => e.LockedFields) - .Include(e => e.UserData); + if (filter.TrailerTypes.Length > 0 || filter.IncludeItemTypes.Contains(BaseItemKind.Trailer)) + { + dbQuery = dbQuery.Include(e => e.TrailerTypes); + } + + if (filter.DtoOptions.ContainsField(ItemFields.ProviderIds)) + { + dbQuery = dbQuery.Include(e => e.Provider); + } + + if (filter.DtoOptions.ContainsField(ItemFields.Settings)) + { + dbQuery = dbQuery.Include(e => e.LockedFields); + } + + if (filter.DtoOptions.EnableUserData) + { + dbQuery = dbQuery.Include(e => e.UserData); + } if (filter.DtoOptions.EnableImages) { @@ -609,7 +624,6 @@ public sealed class BaseItemRepository var ids = tuples.Select(f => f.Item.Id).ToArray(); var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToArray(); - var newItems = tuples.Where(e => !existingItems.Contains(e.Item.Id)).ToArray(); foreach (var item in tuples) { @@ -643,19 +657,6 @@ public sealed class BaseItemRepository context.SaveChanges(); - foreach (var item in newItems) - { - // reattach old userData entries - var userKeys = item.UserDataKey.ToArray(); - var retentionDate = (DateTime?)null; - context.UserData - .Where(e => e.ItemId == PlaceholderId) - .Where(e => userKeys.Contains(e.CustomDataKey)) - .ExecuteUpdate(e => e - .SetProperty(f => f.ItemId, item.Item.Id) - .SetProperty(f => f.RetentionDate, retentionDate)); - } - var itemValueMaps = tuples .Select(e => (e.Item, Values: GetItemValuesToSave(e.Item, e.InheritedTags))) .ToArray(); @@ -752,6 +753,29 @@ public sealed class BaseItemRepository } /// <inheritdoc /> + public async Task ReattachUserDataAsync(BaseItemDto item, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(item); + cancellationToken.ThrowIfCancellationRequested(); + + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + + await using (dbContext.ConfigureAwait(false)) + { + var userKeys = item.GetUserDataKeys().ToArray(); + var retentionDate = (DateTime?)null; + await dbContext.UserData + .Where(e => e.ItemId == PlaceholderId) + .Where(e => userKeys.Contains(e.CustomDataKey)) + .ExecuteUpdateAsync( + e => e + .SetProperty(f => f.ItemId, item.Id) + .SetProperty(f => f.RetentionDate, retentionDate), + cancellationToken).ConfigureAwait(false); + } + } + + /// <inheritdoc /> public BaseItemDto? RetrieveItem(Guid id) { if (id.IsEmpty()) @@ -858,7 +882,7 @@ public sealed class BaseItemRepository } dto.ExtraIds = string.IsNullOrWhiteSpace(entity.ExtraIds) ? [] : entity.ExtraIds.Split('|').Select(e => Guid.Parse(e)).ToArray(); - dto.ProductionLocations = entity.ProductionLocations?.Split('|') ?? []; + dto.ProductionLocations = entity.ProductionLocations?.Split('|', StringSplitOptions.RemoveEmptyEntries) ?? []; dto.Studios = entity.Studios?.Split('|') ?? []; dto.Tags = string.IsNullOrWhiteSpace(entity.Tags) ? [] : entity.Tags.Split('|'); @@ -1020,7 +1044,7 @@ public sealed class BaseItemRepository } entity.ExtraIds = dto.ExtraIds is not null ? string.Join('|', dto.ExtraIds) : null; - entity.ProductionLocations = dto.ProductionLocations is not null ? string.Join('|', dto.ProductionLocations) : null; + entity.ProductionLocations = dto.ProductionLocations is not null ? string.Join('|', dto.ProductionLocations.Where(p => !string.IsNullOrWhiteSpace(p))) : null; entity.Studios = dto.Studios is not null ? string.Join('|', dto.Studios) : null; entity.Tags = dto.Tags is not null ? string.Join('|', dto.Tags) : null; entity.LockedFields = dto.LockedFields is not null ? dto.LockedFields @@ -1144,7 +1168,7 @@ public sealed class BaseItemRepository return type.GetCustomAttribute<RequiresSourceSerialisationAttribute>() == null; } - private BaseItemDto DeserializeBaseItem(BaseItemEntity baseItemEntity, bool skipDeserialization = false) + private BaseItemDto? DeserializeBaseItem(BaseItemEntity baseItemEntity, bool skipDeserialization = false) { ArgumentNullException.ThrowIfNull(baseItemEntity, nameof(baseItemEntity)); if (_serverConfigurationManager?.Configuration is null) @@ -1167,11 +1191,19 @@ public sealed class BaseItemRepository /// <param name="logger">Logger.</param> /// <param name="appHost">The application server Host.</param> /// <param name="skipDeserialization">If only mapping should be processed.</param> - /// <returns>A mapped BaseItem.</returns> - /// <exception cref="InvalidOperationException">Will be thrown if an invalid serialisation is requested.</exception> - public static BaseItemDto DeserializeBaseItem(BaseItemEntity baseItemEntity, ILogger logger, IServerApplicationHost? appHost, bool skipDeserialization = false) + /// <returns>A mapped BaseItem, or null if the item type is unknown.</returns> + public static BaseItemDto? DeserializeBaseItem(BaseItemEntity baseItemEntity, ILogger logger, IServerApplicationHost? appHost, bool skipDeserialization = false) { - var type = GetType(baseItemEntity.Type) ?? throw new InvalidOperationException("Cannot deserialize unknown type."); + var type = GetType(baseItemEntity.Type); + if (type is null) + { + logger.LogWarning( + "Skipping item {ItemId} with unknown type '{ItemType}'. This may indicate a removed plugin or database corruption.", + baseItemEntity.Id, + baseItemEntity.Type); + return null; + } + BaseItemDto? dto = null; if (TypeRequiresDeserialization(type) && baseItemEntity.Data is not null && !skipDeserialization) { @@ -1338,10 +1370,9 @@ public sealed class BaseItemRepository .. resultQuery .AsEnumerable() .Where(e => e is not null) - .Select(e => - { - return (DeserializeBaseItem(e.item, filter.SkipDeserialization), e.itemCount); - }) + .Select(e => (Item: DeserializeBaseItem(e.item, filter.SkipDeserialization), e.itemCount)) + .Where(e => e.Item is not null) + .Select(e => (e.Item!, e.itemCount)) ]; } else @@ -1352,10 +1383,9 @@ public sealed class BaseItemRepository .. query .AsEnumerable() .Where(e => e is not null) - .Select<BaseItemEntity, (BaseItemDto, ItemCounts?)>(e => - { - return (DeserializeBaseItem(e, filter.SkipDeserialization), null); - }) + .Select(e => (Item: DeserializeBaseItem(e, filter.SkipDeserialization), ItemCounts: (ItemCounts?)null)) + .Where(e => e.Item is not null) + .Select(e => (e.Item!, e.ItemCounts)) ]; } @@ -1571,43 +1601,50 @@ public sealed class BaseItemRepository private IQueryable<BaseItemEntity> ApplyOrder(IQueryable<BaseItemEntity> query, InternalItemsQuery filter, JellyfinDbContext context) { - var orderBy = filter.OrderBy; + var orderBy = filter.OrderBy.Where(e => e.OrderBy != ItemSortBy.Default).ToArray(); var hasSearch = !string.IsNullOrEmpty(filter.SearchTerm); if (hasSearch) { - orderBy = filter.OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending), .. orderBy]; + orderBy = [(ItemSortBy.SortName, SortOrder.Ascending), .. orderBy]; } - else if (orderBy.Count == 0) + else if (orderBy.Length == 0) { return query.OrderBy(e => e.SortName); } IOrderedQueryable<BaseItemEntity>? orderedQuery = null; + // When searching, prioritize by match quality: exact match > prefix match > contains + if (hasSearch) + { + orderedQuery = query.OrderBy(OrderMapper.MapSearchRelevanceOrder(filter.SearchTerm!)); + } + var firstOrdering = orderBy.FirstOrDefault(); if (firstOrdering != default) { var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter, context); - if (firstOrdering.SortOrder == SortOrder.Ascending) + if (orderedQuery is null) { - orderedQuery = query.OrderBy(expression); + // No search relevance ordering, start fresh + orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending + ? query.OrderBy(expression) + : query.OrderByDescending(expression); } else { - orderedQuery = query.OrderByDescending(expression); + // Search relevance ordering already applied, chain with ThenBy + orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending + ? orderedQuery.ThenBy(expression) + : orderedQuery.ThenByDescending(expression); } if (firstOrdering.OrderBy is ItemSortBy.Default or ItemSortBy.SortName) { - if (firstOrdering.SortOrder is SortOrder.Ascending) - { - orderedQuery = orderedQuery.ThenBy(e => e.Name); - } - else - { - orderedQuery = orderedQuery.ThenByDescending(e => e.Name); - } + orderedQuery = firstOrdering.SortOrder is SortOrder.Ascending + ? orderedQuery.ThenBy(e => e.Name) + : orderedQuery.ThenByDescending(e => e.Name); } } @@ -1972,8 +2009,15 @@ public sealed class BaseItemRepository if (!string.IsNullOrWhiteSpace(filter.Name)) { - var cleanName = GetCleanValue(filter.Name); - baseQuery = baseQuery.Where(e => e.CleanName == cleanName); + if (filter.UseRawName == true) + { + baseQuery = baseQuery.Where(e => e.Name == filter.Name); + } + else + { + var cleanName = GetCleanValue(filter.Name); + baseQuery = baseQuery.Where(e => e.CleanName == cleanName); + } } // These are the same, for now @@ -2467,35 +2511,24 @@ public sealed class BaseItemRepository if (filter.ExcludeInheritedTags.Length > 0) { + var excludedTags = filter.ExcludeInheritedTags; 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)))); + !e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue)) + && (!e.SeriesId.HasValue || !context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue)))); } if (filter.IncludeInheritedTags.Length > 0) { - // 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!.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)))); - } + var includeTags = filter.IncludeInheritedTags; + var isPlaylistOnlyQuery = includeTypes.Length == 1 && includeTypes.FirstOrDefault() == BaseItemKind.Playlist; + baseQuery = baseQuery.Where(e => + e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.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!.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!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))); - } + // For seasons and episodes, we also need to check the parent series' tags. + || (e.SeriesId.HasValue && context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue))) + + // A playlist should be accessible to its owner regardless of allowed tags + || (isPlaylistOnlyQuery && e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\""))); } if (filter.SeriesStatuses.Length > 0) @@ -2649,6 +2682,6 @@ public sealed class BaseItemRepository .Where(e => artistNames.Contains(e.Name)) .ToArray(); - return artists.GroupBy(e => e.Name).ToDictionary(e => e.Key!, e => e.Select(f => DeserializeBaseItem(f)).Cast<MusicArtist>().ToArray()); + return artists.GroupBy(e => e.Name).ToDictionary(e => e.Key!, e => e.Select(f => DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray()); } } diff --git a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs index 7eb13b740..64874ccad 100644 --- a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs +++ b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs @@ -158,6 +158,12 @@ public class MediaStreamRepository : IMediaStreamRepository dto.LocalizedDefault = _localization.GetLocalizedString("Default"); dto.LocalizedExternal = _localization.GetLocalizedString("External"); + if (!string.IsNullOrEmpty(dto.Language)) + { + var culture = _localization.FindLanguageInfo(dto.Language); + dto.LocalizedLanguage = culture?.DisplayName; + } + if (dto.Type is MediaStreamType.Subtitle) { dto.LocalizedUndefined = _localization.GetLocalizedString("Undefined"); diff --git a/Jellyfin.Server.Implementations/Item/OrderMapper.cs b/Jellyfin.Server.Implementations/Item/OrderMapper.cs index 192ee7499..1ae7cc6c4 100644 --- a/Jellyfin.Server.Implementations/Item/OrderMapper.cs +++ b/Jellyfin.Server.Implementations/Item/OrderMapper.cs @@ -6,6 +6,7 @@ using System.Linq.Expressions; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; using Microsoft.EntityFrameworkCore; @@ -68,4 +69,30 @@ public static class OrderMapper _ => e => e.SortName }; } + + /// <summary> + /// Creates an expression to order search results by match quality. + /// Prioritizes: exact match (0) > prefix match with word boundary (1) > prefix match (2) > contains (3). + /// </summary> + /// <param name="searchTerm">The search term to match against.</param> + /// <returns>An expression that returns an integer representing match quality (lower is better).</returns> + public static Expression<Func<BaseItemEntity, int>> MapSearchRelevanceOrder(string searchTerm) + { + var cleanSearchTerm = GetCleanValue(searchTerm); + var searchPrefix = cleanSearchTerm + " "; + return e => + e.CleanName == cleanSearchTerm ? 0 : + e.CleanName!.StartsWith(searchPrefix) ? 1 : + e.CleanName!.StartsWith(cleanSearchTerm) ? 2 : 3; + } + + private static string GetCleanValue(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return value; + } + + return value.RemoveDiacritics().ToLowerInvariant(); + } } diff --git a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj index 6693ab8db..4f0c37722 100644 --- a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj +++ b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> @@ -27,7 +27,6 @@ <ItemGroup> <PackageReference Include="AsyncKeyedLock" /> - <PackageReference Include="System.Linq.Async" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" /> </ItemGroup> diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 63d962ad9..501cb4fbe 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -149,7 +149,7 @@ namespace Jellyfin.Server.Implementations.Users ThrowIfInvalidUsername(newName); - if (user.Username.Equals(newName, StringComparison.OrdinalIgnoreCase)) + if (user.Username.Equals(newName, StringComparison.Ordinal)) { throw new ArgumentException("The new and old names must be different."); } diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index 04dd19eda..9df24fa0d 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -174,7 +174,7 @@ namespace Jellyfin.Server.Extensions if (config.KnownProxies.Length == 0) { options.ForwardedHeaders = ForwardedHeaders.None; - options.KnownNetworks.Clear(); + options.KnownIPNetworks.Clear(); options.KnownProxies.Clear(); } else @@ -184,7 +184,7 @@ namespace Jellyfin.Server.Extensions } // Only set forward limit if we have some known proxies or some known networks. - if (options.KnownProxies.Count != 0 || options.KnownNetworks.Count != 0) + if (options.KnownProxies.Count != 0 || options.KnownIPNetworks.Count != 0) { options.ForwardLimit = null; } @@ -255,6 +255,7 @@ namespace Jellyfin.Server.Extensions c.AddSwaggerTypeMappings(); c.SchemaFilter<IgnoreEnumSchemaFilter>(); + c.SchemaFilter<FlagsEnumSchemaFilter>(); c.OperationFilter<RetryOnTemporarilyUnavailableFilter>(); c.OperationFilter<SecurityRequirementsOperationFilter>(); c.OperationFilter<FileResponseFilter>(); @@ -289,10 +290,7 @@ namespace Jellyfin.Server.Extensions } else if (NetworkUtils.TryParseToSubnet(allowedProxies[i], out var subnet)) { - if (subnet is not null) - { - AddIPAddress(config, options, subnet.Prefix, subnet.PrefixLength); - } + AddIPAddress(config, options, subnet.Address, subnet.Subnet.PrefixLength); } else if (NetworkUtils.TryParseHost(allowedProxies[i], out var addresses, config.EnableIPv4, config.EnableIPv6)) { @@ -322,7 +320,7 @@ namespace Jellyfin.Server.Extensions } else { - options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(addr, prefixLength)); + options.KnownIPNetworks.Add(new System.Net.IPNetwork(addr, prefixLength)); } } @@ -342,25 +340,6 @@ namespace Jellyfin.Server.Extensions } }); - /* - * Support BlurHash dictionary - */ - options.MapType<Dictionary<ImageType, Dictionary<string, string>>>(() => - new OpenApiSchema - { - Type = "object", - Properties = typeof(ImageType).GetEnumNames().ToDictionary( - name => name, - _ => new OpenApiSchema - { - Type = "object", - AdditionalProperties = new OpenApiSchema - { - Type = "string" - } - }) - }); - // Support dictionary with nullable string value. options.MapType<Dictionary<string, string?>>(() => new OpenApiSchema @@ -373,21 +352,6 @@ namespace Jellyfin.Server.Extensions } }); - // Manually describe Flags enum. - options.MapType<TranscodeReason>(() => - new OpenApiSchema - { - Type = "array", - Items = new OpenApiSchema - { - Reference = new OpenApiReference - { - Id = nameof(TranscodeReason), - Type = ReferenceType.Schema, - } - } - }); - // Swashbuckle doesn't use JsonOptions to describe responses, so we need to manually describe it. options.MapType<Version>(() => new OpenApiSchema { diff --git a/Jellyfin.Server/Filters/AdditionalModelFilter.cs b/Jellyfin.Server/Filters/AdditionalModelFilter.cs index 58d37db5a..7407bd2eb 100644 --- a/Jellyfin.Server/Filters/AdditionalModelFilter.cs +++ b/Jellyfin.Server/Filters/AdditionalModelFilter.cs @@ -225,15 +225,6 @@ namespace Jellyfin.Server.Filters context.SchemaGenerator.GenerateSchema(configuration.ConfigurationType, context.SchemaRepository); } - - context.SchemaRepository.AddDefinition(nameof(TranscodeReason), new OpenApiSchema - { - Type = "string", - Enum = Enum.GetNames<TranscodeReason>() - .Select(e => new OpenApiString(e)) - .Cast<IOpenApiAny>() - .ToArray() - }); } } } diff --git a/Jellyfin.Server/Filters/CachingOpenApiProvider.cs b/Jellyfin.Server/Filters/CachingOpenApiProvider.cs index b560ec50e..833b68444 100644 --- a/Jellyfin.Server/Filters/CachingOpenApiProvider.cs +++ b/Jellyfin.Server/Filters/CachingOpenApiProvider.cs @@ -2,6 +2,7 @@ using System; using AsyncKeyedLock; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.OpenApi.Models; using Swashbuckle.AspNetCore.Swagger; @@ -23,6 +24,7 @@ internal sealed class CachingOpenApiProvider : ISwaggerProvider private readonly IMemoryCache _memoryCache; private readonly SwaggerGenerator _swaggerGenerator; private readonly SwaggerGeneratorOptions _swaggerGeneratorOptions; + private readonly ILogger<CachingOpenApiProvider> _logger; /// <summary> /// Initializes a new instance of the <see cref="CachingOpenApiProvider"/> class. @@ -31,15 +33,18 @@ internal sealed class CachingOpenApiProvider : ISwaggerProvider /// <param name="apiDescriptionsProvider">The api descriptions provider.</param> /// <param name="schemaGenerator">The schema generator.</param> /// <param name="memoryCache">The memory cache.</param> + /// <param name="logger">The logger.</param> public CachingOpenApiProvider( IOptions<SwaggerGeneratorOptions> optionsAccessor, IApiDescriptionGroupCollectionProvider apiDescriptionsProvider, ISchemaGenerator schemaGenerator, - IMemoryCache memoryCache) + IMemoryCache memoryCache, + ILogger<CachingOpenApiProvider> logger) { _swaggerGeneratorOptions = optionsAccessor.Value; _swaggerGenerator = new SwaggerGenerator(_swaggerGeneratorOptions, apiDescriptionsProvider, schemaGenerator); _memoryCache = memoryCache; + _logger = logger; } /// <inheritdoc /> @@ -61,7 +66,16 @@ internal sealed class CachingOpenApiProvider : ISwaggerProvider throw new InvalidOperationException("OpenApi document is generating"); } + try + { openApiDocument = _swaggerGenerator.GetSwagger(documentName); + } + catch (Exception ex) + { + _logger.LogError(ex, "OpenAPI generation error"); + throw; + } + _memoryCache.Set(CacheKey, openApiDocument, _cacheOptions); return AdjustDocument(openApiDocument, host, basePath); } diff --git a/Jellyfin.Server/Filters/FlagsEnumSchemaFilter.cs b/Jellyfin.Server/Filters/FlagsEnumSchemaFilter.cs new file mode 100644 index 000000000..3e0b69d01 --- /dev/null +++ b/Jellyfin.Server/Filters/FlagsEnumSchemaFilter.cs @@ -0,0 +1,53 @@ +using System; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Jellyfin.Server.Filters; + +/// <summary> +/// Schema filter to ensure flags enums are represented correctly in OpenAPI. +/// </summary> +/// <remarks> +/// For flags enums: +/// - The enum schema definition is set to type "string" (not integer). +/// - Properties using flags enums are transformed to arrays referencing the enum schema. +/// </remarks> +public class FlagsEnumSchemaFilter : ISchemaFilter +{ + /// <inheritdoc /> + public void Apply(OpenApiSchema schema, SchemaFilterContext context) + { + var type = context.Type.IsEnum ? context.Type : Nullable.GetUnderlyingType(context.Type); + if (type is null || !type.IsEnum) + { + return; + } + + // Check if enum has [Flags] attribute + if (!type.IsDefined(typeof(FlagsAttribute), false)) + { + return; + } + + if (context.MemberInfo is null) + { + // Processing the enum definition itself - ensure it's type "string" not "integer" + schema.Type = "string"; + schema.Format = null; + } + else + { + // Processing a property that uses the flags enum - transform to array + // Generate the enum schema to ensure it exists in the repository + var enumSchema = context.SchemaGenerator.GenerateSchema(type, context.SchemaRepository); + + // Flags enums should be represented as arrays referencing the enum schema + // since multiple values can be combined + schema.Type = "array"; + schema.Format = null; + schema.Enum = null; + schema.AllOf = null; + schema.Items = enumSchema; + } + } +} diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj index 14ab114fb..9f5bf01a0 100644 --- a/Jellyfin.Server/Jellyfin.Server.csproj +++ b/Jellyfin.Server/Jellyfin.Server.csproj @@ -8,7 +8,7 @@ <PropertyGroup> <AssemblyName>jellyfin</AssemblyName> <OutputType>Exe</OutputType> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <ServerGarbageCollection>false</ServerGarbageCollection> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> @@ -44,9 +44,6 @@ <ItemGroup> <PackageReference Include="CommandLineParser" /> - <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" /> - <PackageReference Include="Microsoft.Extensions.Configuration.Json" /> - <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" /> <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" /> <PackageReference Include="Morestachio" /> <PackageReference Include="prometheus-net" /> diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs index d221d1853..4b1e53a35 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs @@ -1247,8 +1247,11 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine } var baseItem = BaseItemRepository.DeserializeBaseItem(entity, _logger, null, false); - var dataKeys = baseItem.GetUserDataKeys(); - userDataKeys.AddRange(dataKeys); + if (baseItem is not null) + { + var dataKeys = baseItem.GetUserDataKeys(); + userDataKeys.AddRange(dataKeys); + } return (entity, userDataKeys.ToArray()); } diff --git a/MediaBrowser.Common/MediaBrowser.Common.csproj b/MediaBrowser.Common/MediaBrowser.Common.csproj index 5f15f845c..c128c2b6b 100644 --- a/MediaBrowser.Common/MediaBrowser.Common.csproj +++ b/MediaBrowser.Common/MediaBrowser.Common.csproj @@ -19,16 +19,11 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" /> - <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" /> - </ItemGroup> - - <ItemGroup> <Compile Include="..\SharedVersion.cs" /> </ItemGroup> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <PublishRepositoryUrl>true</PublishRepositoryUrl> diff --git a/MediaBrowser.Common/Net/NetworkConstants.cs b/MediaBrowser.Common/Net/NetworkConstants.cs index ccef5d271..cec996a1a 100644 --- a/MediaBrowser.Common/Net/NetworkConstants.cs +++ b/MediaBrowser.Common/Net/NetworkConstants.cs @@ -1,5 +1,4 @@ using System.Net; -using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork; namespace MediaBrowser.Common.Net; diff --git a/MediaBrowser.Common/Net/NetworkUtils.cs b/MediaBrowser.Common/Net/NetworkUtils.cs index 24ed47a81..5c854b39d 100644 --- a/MediaBrowser.Common/Net/NetworkUtils.cs +++ b/MediaBrowser.Common/Net/NetworkUtils.cs @@ -6,7 +6,7 @@ using System.Net; using System.Net.Sockets; using System.Text.RegularExpressions; using Jellyfin.Extensions; -using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork; +using MediaBrowser.Model.Net; namespace MediaBrowser.Common.Net; @@ -167,7 +167,7 @@ public static partial class NetworkUtils /// <param name="result">Collection of <see cref="IPNetwork"/>.</param> /// <param name="negated">Boolean signaling if negated or not negated values should be parsed.</param> /// <returns><c>True</c> if parsing was successful.</returns> - public static bool TryParseToSubnets(string[] values, [NotNullWhen(true)] out IReadOnlyList<IPNetwork>? result, bool negated = false) + public static bool TryParseToSubnets(string[] values, [NotNullWhen(true)] out IReadOnlyList<IPData>? result, bool negated = false) { if (values is null || values.Length == 0) { @@ -175,28 +175,28 @@ public static partial class NetworkUtils return false; } - var tmpResult = new List<IPNetwork>(); + List<IPData>? tmpResult = null; for (int a = 0; a < values.Length; a++) { if (TryParseToSubnet(values[a], out var innerResult, negated)) { - tmpResult.Add(innerResult); + (tmpResult ??= new()).Add(innerResult); } } result = tmpResult; - return tmpResult.Count > 0; + return result is not null; } /// <summary> - /// Try parsing a string into an <see cref="IPNetwork"/>, respecting exclusions. - /// Inputs without a subnet mask will be represented as <see cref="IPNetwork"/> with a single IP. + /// Try parsing a string into an <see cref="IPData"/>, respecting exclusions. + /// Inputs without a subnet mask will be represented as <see cref="IPData"/> with a single IP. /// </summary> /// <param name="value">Input string to be parsed.</param> - /// <param name="result">An <see cref="IPNetwork"/>.</param> + /// <param name="result">An <see cref="IPData"/>.</param> /// <param name="negated">Boolean signaling if negated or not negated values should be parsed.</param> /// <returns><c>True</c> if parsing was successful.</returns> - public static bool TryParseToSubnet(ReadOnlySpan<char> value, [NotNullWhen(true)] out IPNetwork? result, bool negated = false) + public static bool TryParseToSubnet(ReadOnlySpan<char> value, [NotNullWhen(true)] out IPData? result, bool negated = false) { // If multiple IP addresses are in a comma-separated string, the individual addresses may contain leading and/or trailing whitespace value = value.Trim(); @@ -210,14 +210,16 @@ public static partial class NetworkUtils if (isAddressNegated != negated) { - result = null; + result = default; return false; } - if (value.Contains('/')) + var index = value.IndexOf('/'); + if (index != -1) { - if (IPNetwork.TryParse(value, out result)) + if (IPAddress.TryParse(value[..index], out var address) && IPNetwork.TryParse(value, out var subnet)) { + result = new IPData(address, subnet); return true; } } @@ -225,17 +227,17 @@ public static partial class NetworkUtils { if (address.AddressFamily == AddressFamily.InterNetwork) { - result = address.Equals(IPAddress.Any) ? NetworkConstants.IPv4Any : new IPNetwork(address, NetworkConstants.MinimumIPv4PrefixSize); + result = address.Equals(IPAddress.Any) ? new IPData(IPAddress.Any, NetworkConstants.IPv4Any) : new IPData(address, new IPNetwork(address, NetworkConstants.MinimumIPv4PrefixSize)); return true; } else if (address.AddressFamily == AddressFamily.InterNetworkV6) { - result = address.Equals(IPAddress.IPv6Any) ? NetworkConstants.IPv6Any : new IPNetwork(address, NetworkConstants.MinimumIPv6PrefixSize); + result = address.Equals(IPAddress.IPv6Any) ? new IPData(IPAddress.IPv6Any, NetworkConstants.IPv6Any) : new IPData(address, new IPNetwork(address, NetworkConstants.MinimumIPv6PrefixSize)); return true; } } - result = null; + result = default; return false; } @@ -330,7 +332,7 @@ public static partial class NetworkUtils /// <returns>The broadcast address.</returns> public static IPAddress GetBroadcastAddress(IPNetwork network) { - var addressBytes = network.Prefix.GetAddressBytes(); + var addressBytes = network.BaseAddress.GetAddressBytes(); uint ipAddress = BitConverter.ToUInt32(addressBytes, 0); uint ipMaskV4 = BitConverter.ToUInt32(CidrToMask(network.PrefixLength, AddressFamily.InterNetwork).GetAddressBytes(), 0); uint broadCastIPAddress = ipAddress | ~ipMaskV4; @@ -347,7 +349,6 @@ public static partial class NetworkUtils public static bool SubnetContainsAddress(IPNetwork network, IPAddress address) { ArgumentNullException.ThrowIfNull(address); - ArgumentNullException.ThrowIfNull(network); if (address.IsIPv4MappedToIPv6) { diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 3c46d53e5..7586b99e7 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -1620,12 +1620,17 @@ namespace MediaBrowser.Controller.Entities return isAllowed; } - if (maxAllowedSubRating is not null) + if (!maxAllowedRating.HasValue) { - return (ratingScore.SubScore ?? 0) <= maxAllowedSubRating && ratingScore.Score <= maxAllowedRating.Value; + return true; } - return !maxAllowedRating.HasValue || ratingScore.Score <= maxAllowedRating.Value; + if (ratingScore.Score != maxAllowedRating.Value) + { + return ratingScore.Score < maxAllowedRating.Value; + } + + return !maxAllowedSubRating.HasValue || (ratingScore.SubScore ?? 0) <= maxAllowedSubRating.Value; } public ParentalRatingScore GetParentalRatingScore() @@ -2048,6 +2053,9 @@ namespace MediaBrowser.Controller.Entities public virtual async Task UpdateToRepositoryAsync(ItemUpdateType updateReason, CancellationToken cancellationToken) => await LibraryManager.UpdateItemAsync(this, GetParent(), updateReason, cancellationToken).ConfigureAwait(false); + public async Task ReattachUserDataAsync(CancellationToken cancellationToken) => + await LibraryManager.ReattachUserDataAsync(this, cancellationToken).ConfigureAwait(false); + /// <summary> /// Validates that images within the item are still on the filesystem. /// </summary> diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index 59a967725..d2a3290c4 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -1406,13 +1406,6 @@ namespace MediaBrowser.Controller.Entities .Where(e => query is null || UserViewBuilder.FilterItem(e, query)) .ToArray(); - if (this is BoxSet && (query.OrderBy is null || query.OrderBy.Count == 0)) - { - realChildren = realChildren - .OrderBy(e => e.PremiereDate ?? DateTime.MaxValue) - .ToArray(); - } - var childCount = realChildren.Length; if (result.Count < limit) { diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs index b32b64f5d..076a59292 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -125,6 +125,8 @@ namespace MediaBrowser.Controller.Entities public string? Name { get; set; } + public bool? UseRawName { get; set; } + public string? Person { get; set; } public Guid[] PersonIds { get; set; } diff --git a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs index 1d1fb2c39..3999c3e07 100644 --- a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs +++ b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs @@ -124,7 +124,7 @@ namespace MediaBrowser.Controller.Entities.Movies if (sortBy == ItemSortBy.Default) { - return items; + return items; } return LibraryManager.Sort(items, user, new[] { sortBy }, SortOrder.Ascending); @@ -136,6 +136,12 @@ namespace MediaBrowser.Controller.Entities.Movies return Sort(children, user).ToArray(); } + public override IReadOnlyList<BaseItem> GetChildren(User user, bool includeLinkedChildren, out int totalItemCount, InternalItemsQuery query = null) + { + var children = base.GetChildren(user, includeLinkedChildren, out totalItemCount, query); + return Sort(children, user).ToArray(); + } + public override IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query, out int totalCount) { var children = base.GetRecursiveChildren(user, query, out totalCount); diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs index 427c2995b..6396631f9 100644 --- a/MediaBrowser.Controller/Entities/TV/Series.cs +++ b/MediaBrowser.Controller/Entities/TV/Series.cs @@ -214,7 +214,7 @@ namespace MediaBrowser.Controller.Entities.TV query.AncestorWithPresentationUniqueKey = null; query.SeriesPresentationUniqueKey = seriesKey; query.IncludeItemTypes = new[] { BaseItemKind.Season }; - query.OrderBy = new[] { (ItemSortBy.IndexNumber, SortOrder.Ascending) }; + query.OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }; if (user is not null && !user.DisplayMissingEpisodes) { @@ -247,6 +247,10 @@ namespace MediaBrowser.Controller.Entities.TV query.AncestorWithPresentationUniqueKey = null; query.SeriesPresentationUniqueKey = seriesKey; + if (query.OrderBy.Count == 0) + { + query.OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }; + } if (query.IncludeItemTypes.Length == 0) { diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index fcc5ed672..df1c98f3f 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -282,6 +282,14 @@ namespace MediaBrowser.Controller.Library Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken); /// <summary> + /// Reattaches the user data to the item. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>A task that represents the asynchronous reattachment operation.</returns> + Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken); + + /// <summary> /// Retrieves the item. /// </summary> /// <param name="id">The id.</param> @@ -652,5 +660,12 @@ namespace MediaBrowser.Controller.Library /// This exists so plugins can trigger a library scan. /// </remarks> void QueueLibraryScan(); + + /// <summary> + /// Add mblink file for a media path. + /// </summary> + /// <param name="virtualFolderPath">The path to the virtualfolder.</param> + /// <param name="pathInfo">The new virtualfolder.</param> + public void CreateShortcut(string virtualFolderPath, MediaPathInfo pathInfo); } } diff --git a/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs b/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs index 0de5f198d..6da398129 100644 --- a/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs +++ b/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading; +using System.Threading.Channels; using System.Threading.Tasks; using MediaBrowser.Controller.Configuration; using Microsoft.Extensions.Hosting; @@ -29,7 +30,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr /// </summary> private readonly Lock _taskLock = new(); - private readonly BlockingCollection<TaskQueueItem> _tasks = new(); + private readonly Channel<TaskQueueItem> _tasks = Channel.CreateUnbounded<TaskQueueItem>(); private volatile int _workCounter; private Task? _cleanupTask; @@ -77,7 +78,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr lock (_taskLock) { - if (_tasks.Count > 0 || _workCounter > 0) + if (_tasks.Reader.Count > 0 || _workCounter > 0) { _logger.LogDebug("Delay cleanup task, operations still running."); // tasks are still there so its still in use. Reschedule cleanup task. @@ -144,9 +145,9 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr _deadlockDetector.Value = stopToken.TaskStop; try { - foreach (var item in _tasks.GetConsumingEnumerable(stopToken.GlobalStop.Token)) + while (!stopToken.GlobalStop.Token.IsCancellationRequested) { - stopToken.GlobalStop.Token.ThrowIfCancellationRequested(); + var item = await _tasks.Reader.ReadAsync(stopToken.GlobalStop.Token).ConfigureAwait(false); try { var newWorkerLimit = Interlocked.Increment(ref _workCounter) > 0; @@ -187,7 +188,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr await item.Worker(item.Data).ConfigureAwait(true); } - catch (System.Exception ex) + catch (Exception ex) { _logger.LogError(ex, "Error while performing a library operation"); } @@ -242,7 +243,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr }; }).ToArray(); - if (ShouldForceSequentialOperation()) + if (ShouldForceSequentialOperation() || _deadlockDetector.Value is not null) { _logger.LogDebug("Process sequentially."); try @@ -264,35 +265,14 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr for (var i = 0; i < workItems.Length; i++) { var item = workItems[i]!; - _tasks.Add(item, CancellationToken.None); + await _tasks.Writer.WriteAsync(item, CancellationToken.None).ConfigureAwait(false); } - if (_deadlockDetector.Value is not null) - { - _logger.LogDebug("Nested invocation detected, process in-place."); - try - { - // we are in a nested loop. There is no reason to spawn a task here as that would just lead to deadlocks and no additional concurrency is achieved - while (workItems.Any(e => !e.Done.Task.IsCompleted) && _tasks.TryTake(out var item, 200, _deadlockDetector.Value.Token)) - { - await ProcessItem(item).ConfigureAwait(false); - } - } - catch (OperationCanceledException) when (_deadlockDetector.Value.IsCancellationRequested) - { - // operation is cancelled. Do nothing. - } - - _logger.LogDebug("process in-place done."); - } - else - { - Worker(); - _logger.LogDebug("Wait for {NoWorkers} to complete.", workItems.Length); - await Task.WhenAll([.. workItems.Select(f => f.Done.Task)]).ConfigureAwait(false); - _logger.LogDebug("{NoWorkers} completed.", workItems.Length); - ScheduleTaskCleanup(); - } + Worker(); + _logger.LogDebug("Wait for {NoWorkers} to complete.", workItems.Length); + await Task.WhenAll([.. workItems.Select(f => f.Done.Task)]).ConfigureAwait(false); + _logger.LogDebug("{NoWorkers} completed.", workItems.Length); + ScheduleTaskCleanup(); } /// <inheritdoc/> @@ -304,13 +284,12 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr } _disposed = true; - _tasks.CompleteAdding(); + _tasks.Writer.Complete(); foreach (var item in _taskRunners) { await item.Key.CancelAsync().ConfigureAwait(false); } - _tasks.Dispose(); if (_cleanupTask is not null) { await _cleanupTask.ConfigureAwait(false); diff --git a/MediaBrowser.Controller/MediaBrowser.Controller.csproj b/MediaBrowser.Controller/MediaBrowser.Controller.csproj index b5d14e94b..0025080cc 100644 --- a/MediaBrowser.Controller/MediaBrowser.Controller.csproj +++ b/MediaBrowser.Controller/MediaBrowser.Controller.csproj @@ -19,9 +19,7 @@ <ItemGroup> <PackageReference Include="BitFaster.Caching" /> - <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" /> <PackageReference Include="Microsoft.Extensions.Configuration.Binder" /> - <PackageReference Include="System.Threading.Tasks.Dataflow" /> </ItemGroup> <ItemGroup> @@ -36,7 +34,7 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <PublishRepositoryUrl>true</PublishRepositoryUrl> diff --git a/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs b/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs index 20f51ddb7..10f2f04af 100644 --- a/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs +++ b/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs @@ -43,8 +43,6 @@ namespace MediaBrowser.Controller.MediaEncoding public bool AllowAudioStreamCopy { get; set; } - public bool BreakOnNonKeyFrames { get; set; } - /// <summary> /// Gets or sets the audio sample rate. /// </summary> diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 843590a1f..11eee1a37 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -2914,8 +2914,8 @@ namespace MediaBrowser.Controller.MediaEncoding if (time > 0) { - // For direct streaming/remuxing, we seek at the exact position of the keyframe - // However, ffmpeg will seek to previous keyframe when the exact time is the input + // For direct streaming/remuxing, HLS segments start at keyframes. + // However, ffmpeg will seek to previous keyframe when the exact frame time is the input // Workaround this by adding 0.5s offset to the seeking time to get the exact keyframe on most videos. // This will help subtitle syncing. var isHlsRemuxing = state.IsVideoRequest && state.TranscodingType is TranscodingJobType.Hls && IsCopyCodec(state.OutputVideoCodec); @@ -2932,17 +2932,16 @@ namespace MediaBrowser.Controller.MediaEncoding if (state.IsVideoRequest) { - var outputVideoCodec = GetVideoEncoder(state, options); - var segmentFormat = GetSegmentFileExtension(segmentContainer).TrimStart('.'); - - // Important: If this is ever re-enabled, make sure not to use it with wtv because it breaks seeking - // Disable -noaccurate_seek on mpegts container due to the timestamps issue on some clients, - // but it's still required for fMP4 container otherwise the audio can't be synced to the video. - if (!string.Equals(state.InputContainer, "wtv", StringComparison.OrdinalIgnoreCase) - && !string.Equals(segmentFormat, "ts", StringComparison.OrdinalIgnoreCase) - && state.TranscodingType != TranscodingJobType.Progressive - && !state.EnableBreakOnNonKeyFrames(outputVideoCodec) - && (state.BaseRequest.StartTimeTicks ?? 0) > 0) + // If we are remuxing, then the copied stream cannot be seeked accurately (it will seek to the nearest + // keyframe). If we are using fMP4, then force all other streams to use the same inaccurate seeking to + // avoid A/V sync issues which cause playback issues on some devices. + // When remuxing video, the segment start times correspond to key frames in the source stream, so this + // option shouldn't change the seeked point that much. + // Important: make sure not to use it with wtv because it breaks seeking + if (state.TranscodingType is TranscodingJobType.Hls + && string.Equals(segmentContainer, "mp4", StringComparison.OrdinalIgnoreCase) + && (IsCopyCodec(state.OutputVideoCodec) || IsCopyCodec(state.OutputAudioCodec)) + && !string.Equals(state.InputContainer, "wtv", StringComparison.OrdinalIgnoreCase)) { seekParam += " -noaccurate_seek"; } @@ -6359,6 +6358,21 @@ namespace MediaBrowser.Controller.MediaEncoding } } + // Block unsupported H.264 Hi422P and Hi444PP profiles, which can be encoded with 4:2:0 pixel format + if (string.Equals(videoStream.Codec, "h264", StringComparison.OrdinalIgnoreCase)) + { + if (videoStream.Profile.Contains("4:2:2", StringComparison.OrdinalIgnoreCase) + || videoStream.Profile.Contains("4:4:4", StringComparison.OrdinalIgnoreCase)) + { + // VideoToolbox on Apple Silicon has H.264 Hi444PP and theoretically also has Hi422P + if (!(hardwareAccelerationType == HardwareAccelerationType.videotoolbox + && RuntimeInformation.OSArchitecture.Equals(Architecture.Arm64))) + { + return null; + } + } + } + var decoder = hardwareAccelerationType switch { HardwareAccelerationType.vaapi => GetVaapiVidDecoder(state, options, videoStream, bitDepth), @@ -7039,8 +7053,8 @@ namespace MediaBrowser.Controller.MediaEncoding if (string.Equals(videoStream.Codec, "av1", StringComparison.OrdinalIgnoreCase)) { - var accelType = GetHwaccelType(state, options, "av1", bitDepth, hwSurface); - return accelType + ((!string.IsNullOrEmpty(accelType) && isAfbcSupported) ? " -afbc rga" : string.Empty); + // there's an issue about AV1 AFBC on RK3588, disable it for now until it's fixed upstream + return GetHwaccelType(state, options, "av1", bitDepth, hwSurface); } } @@ -7069,7 +7083,7 @@ namespace MediaBrowser.Controller.MediaEncoding } #nullable disable - public void TryStreamCopy(EncodingJobInfo state) + public void TryStreamCopy(EncodingJobInfo state, EncodingOptions options) { if (state.VideoStream is not null && CanStreamCopyVideo(state, state.VideoStream)) { @@ -7086,8 +7100,14 @@ namespace MediaBrowser.Controller.MediaEncoding } } + var preventHlsAudioCopy = state.TranscodingType is TranscodingJobType.Hls + && state.VideoStream is not null + && !IsCopyCodec(state.OutputVideoCodec) + && options.HlsAudioSeekStrategy is HlsAudioSeekStrategy.TranscodeAudio; + if (state.AudioStream is not null - && CanStreamCopyAudio(state, state.AudioStream, state.SupportedAudioCodecs)) + && CanStreamCopyAudio(state, state.AudioStream, state.SupportedAudioCodecs) + && !preventHlsAudioCopy) { state.OutputAudioCodec = "copy"; } diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs index 43680f5c0..7d0384ef2 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs @@ -515,21 +515,6 @@ namespace MediaBrowser.Controller.MediaEncoding public int HlsListSize => 0; - public bool EnableBreakOnNonKeyFrames(string videoCodec) - { - if (TranscodingType != TranscodingJobType.Progressive) - { - if (IsSegmentedLiveStream) - { - return false; - } - - return BaseRequest.BreakOnNonKeyFrames && EncodingHelper.IsCopyCodec(videoCodec); - } - - return false; - } - private int? GetMediaStreamCount(MediaStreamType type, int limit) { var count = MediaSource.GetStreamCount(type); diff --git a/MediaBrowser.Controller/MediaEncoding/JobLogger.cs b/MediaBrowser.Controller/MediaEncoding/JobLogger.cs index 3d288b9f8..2702e3bc0 100644 --- a/MediaBrowser.Controller/MediaEncoding/JobLogger.cs +++ b/MediaBrowser.Controller/MediaEncoding/JobLogger.cs @@ -27,10 +27,9 @@ namespace MediaBrowser.Controller.MediaEncoding using (target) using (reader) { - while (!reader.EndOfStream && reader.BaseStream.CanRead) + string line = await reader.ReadLineAsync().ConfigureAwait(false); + while (line is not null && reader.BaseStream.CanRead) { - var line = await reader.ReadLineAsync().ConfigureAwait(false); - ParseLogLine(line, state); var bytes = Encoding.UTF8.GetBytes(Environment.NewLine + line); @@ -50,6 +49,7 @@ namespace MediaBrowser.Controller.MediaEncoding } await target.FlushAsync().ConfigureAwait(false); + line = await reader.ReadLineAsync().ConfigureAwait(false); } } } diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs index 00c492742..bf80b7d0a 100644 --- a/MediaBrowser.Controller/Persistence/IItemRepository.cs +++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs @@ -36,6 +36,14 @@ public interface IItemRepository Task SaveImagesAsync(BaseItem item, CancellationToken cancellationToken = default); /// <summary> + /// Reattaches the user data to the item. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>A task that represents the asynchronous reattachment operation.</returns> + Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken); + + /// <summary> /// Retrieves the item. /// </summary> /// <param name="id">The id.</param> diff --git a/MediaBrowser.Controller/Sorting/SortExtensions.cs b/MediaBrowser.Controller/Sorting/SortExtensions.cs index f9c0d39dd..ec8878dcb 100644 --- a/MediaBrowser.Controller/Sorting/SortExtensions.cs +++ b/MediaBrowser.Controller/Sorting/SortExtensions.cs @@ -1,7 +1,9 @@ #pragma warning disable CS1591 using System; +using System.Collections; using System.Collections.Generic; +using System.Globalization; using System.Linq; using Jellyfin.Extensions; @@ -9,7 +11,7 @@ namespace MediaBrowser.Controller.Sorting { public static class SortExtensions { - private static readonly AlphanumericComparator _comparer = new AlphanumericComparator(); + private static readonly StringComparer _comparer = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering); public static IEnumerable<T> OrderByString<T>(this IEnumerable<T> list, Func<T, string> getName) { diff --git a/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj b/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj index 8e3c8cf7f..c3c26085c 100644 --- a/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj +++ b/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj @@ -11,7 +11,7 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> diff --git a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj index be7eeda92..fc11047a7 100644 --- a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj +++ b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj @@ -6,7 +6,7 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> @@ -26,7 +26,6 @@ <PackageReference Include="BDInfo" /> <PackageReference Include="libse" /> <PackageReference Include="Microsoft.Extensions.Http" /> - <PackageReference Include="System.Text.Encoding.CodePages" /> <PackageReference Include="UTF.Unknown" /> </ItemGroup> diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index 1823496bf..50f7716d8 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -300,9 +300,12 @@ namespace MediaBrowser.MediaEncoding.Probing // Handle WebM else if (string.Equals(splitFormat[i], "webm", StringComparison.OrdinalIgnoreCase)) { - // Limit WebM to supported codecs - if (mediaStreams.Any(stream => (stream.Type == MediaStreamType.Video && !_webmVideoCodecs.Contains(stream.Codec, StringComparison.OrdinalIgnoreCase)) - || (stream.Type == MediaStreamType.Audio && !_webmAudioCodecs.Contains(stream.Codec, StringComparison.OrdinalIgnoreCase)))) + // Limit WebM to supported stream types and codecs. + // FFprobe can report "matroska,webm" for Matroska-like containers, so only keep "webm" if all streams are WebM-compatible. + // Any stream that is not video nor audio is not supported in WebM and should disqualify the webm container probe result. + if (mediaStreams.Any(stream => stream.Type is not MediaStreamType.Video and not MediaStreamType.Audio) + || mediaStreams.Any(stream => (stream.Type == MediaStreamType.Video && !_webmVideoCodecs.Contains(stream.Codec, StringComparison.OrdinalIgnoreCase)) + || (stream.Type == MediaStreamType.Audio && !_webmAudioCodecs.Contains(stream.Codec, StringComparison.OrdinalIgnoreCase)))) { splitFormat[i] = string.Empty; } @@ -854,7 +857,12 @@ namespace MediaBrowser.MediaEncoding.Probing } // http://stackoverflow.com/questions/17353387/how-to-detect-anamorphic-video-with-ffprobe - if (string.Equals(streamInfo.SampleAspectRatio, "1:1", StringComparison.Ordinal)) + if (string.IsNullOrEmpty(streamInfo.SampleAspectRatio) + && string.IsNullOrEmpty(streamInfo.DisplayAspectRatio)) + { + stream.IsAnamorphic = false; + } + else if (string.Equals(streamInfo.SampleAspectRatio, "1:1", StringComparison.Ordinal)) { stream.IsAnamorphic = false; } diff --git a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs index 2fd054f11..defd855ec 100644 --- a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs +++ b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs @@ -673,7 +673,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable if (state.VideoRequest is not null) { - _encodingHelper.TryStreamCopy(state); + _encodingHelper.TryStreamCopy(state, encodingOptions); } } diff --git a/MediaBrowser.Model/Configuration/EncodingOptions.cs b/MediaBrowser.Model/Configuration/EncodingOptions.cs index f7f386d28..98fc2e632 100644 --- a/MediaBrowser.Model/Configuration/EncodingOptions.cs +++ b/MediaBrowser.Model/Configuration/EncodingOptions.cs @@ -1,6 +1,7 @@ #pragma warning disable CA1819 // XML serialization handles collections improperly, so we need to use arrays #nullable disable +using System.ComponentModel; using MediaBrowser.Model.Entities; namespace MediaBrowser.Model.Configuration; @@ -60,6 +61,7 @@ public class EncodingOptions SubtitleExtractionTimeoutMinutes = 30; AllowOnDemandMetadataBasedKeyframeExtractionForExtensions = ["mkv"]; HardwareDecodingCodecs = ["h264", "vc1"]; + HlsAudioSeekStrategy = HlsAudioSeekStrategy.DisableAccurateSeek; } /// <summary> @@ -301,4 +303,10 @@ public class EncodingOptions /// Gets or sets the file extensions on-demand metadata based keyframe extraction is enabled for. /// </summary> public string[] AllowOnDemandMetadataBasedKeyframeExtractionForExtensions { get; set; } + + /// <summary> + /// Gets or sets the method used for audio seeking in HLS. + /// </summary> + [DefaultValue(HlsAudioSeekStrategy.DisableAccurateSeek)] + public HlsAudioSeekStrategy HlsAudioSeekStrategy { get; set; } } diff --git a/MediaBrowser.Model/Configuration/HlsAudioSeekStrategy.cs b/MediaBrowser.Model/Configuration/HlsAudioSeekStrategy.cs new file mode 100644 index 000000000..49feeb435 --- /dev/null +++ b/MediaBrowser.Model/Configuration/HlsAudioSeekStrategy.cs @@ -0,0 +1,23 @@ +namespace MediaBrowser.Model.Configuration +{ + /// <summary> + /// An enum representing the options to seek the input audio stream when + /// transcoding HLS segments. + /// </summary> + public enum HlsAudioSeekStrategy + { + /// <summary> + /// If the video stream is transcoded and the audio stream is copied, + /// seek the video stream to the same keyframe as the audio stream. The + /// resulting timestamps in the output streams may be inaccurate. + /// </summary> + DisableAccurateSeek = 0, + + /// <summary> + /// Prevent audio streams from being copied if the video stream is transcoded. + /// The resulting timestamps will be accurate, but additional audio transcoding + /// overhead will be incurred. + /// </summary> + TranscodeAudio = 1, + } +} diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs index 61e04a813..42cb208d0 100644 --- a/MediaBrowser.Model/Dlna/StreamBuilder.cs +++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs @@ -610,7 +610,6 @@ namespace MediaBrowser.Model.Dlna playlistItem.EnableSubtitlesInManifest = transcodingProfile.EnableSubtitlesInManifest; playlistItem.EnableMpegtsM2TsMode = transcodingProfile.EnableMpegtsM2TsMode; - playlistItem.BreakOnNonKeyFrames = transcodingProfile.BreakOnNonKeyFrames; playlistItem.EnableAudioVbrEncoding = transcodingProfile.EnableAudioVbrEncoding; if (transcodingProfile.MinSegments > 0) diff --git a/MediaBrowser.Model/Dlna/StreamInfo.cs b/MediaBrowser.Model/Dlna/StreamInfo.cs index 3efb143bc..551bee89e 100644 --- a/MediaBrowser.Model/Dlna/StreamInfo.cs +++ b/MediaBrowser.Model/Dlna/StreamInfo.cs @@ -87,11 +87,6 @@ public class StreamInfo public int? MinSegments { get; set; } /// <summary> - /// Gets or sets a value indicating whether the stream can be broken on non-keyframes. - /// </summary> - public bool BreakOnNonKeyFrames { get; set; } - - /// <summary> /// Gets or sets a value indicating whether the stream requires AVC. /// </summary> public bool RequireAvc { get; set; } @@ -1018,9 +1013,6 @@ public class StreamInfo sb.Append("&MinSegments="); sb.Append(MinSegments.Value.ToString(CultureInfo.InvariantCulture)); } - - sb.Append("&BreakOnNonKeyFrames="); - sb.Append(BreakOnNonKeyFrames.ToString(CultureInfo.InvariantCulture)); } else { diff --git a/MediaBrowser.Model/Dlna/TranscodingProfile.cs b/MediaBrowser.Model/Dlna/TranscodingProfile.cs index 5797d4250..f49b24976 100644 --- a/MediaBrowser.Model/Dlna/TranscodingProfile.cs +++ b/MediaBrowser.Model/Dlna/TranscodingProfile.cs @@ -41,7 +41,6 @@ public class TranscodingProfile MaxAudioChannels = other.MaxAudioChannels; MinSegments = other.MinSegments; SegmentLength = other.SegmentLength; - BreakOnNonKeyFrames = other.BreakOnNonKeyFrames; Conditions = other.Conditions; EnableAudioVbrEncoding = other.EnableAudioVbrEncoding; } @@ -143,7 +142,8 @@ public class TranscodingProfile /// </summary> [DefaultValue(false)] [XmlAttribute("breakOnNonKeyFrames")] - public bool BreakOnNonKeyFrames { get; set; } + [Obsolete("This is always false")] + public bool? BreakOnNonKeyFrames { get; set; } /// <summary> /// Gets or sets the profile conditions. diff --git a/MediaBrowser.Model/Entities/MediaStream.cs b/MediaBrowser.Model/Entities/MediaStream.cs index b1626e2c9..c443af32c 100644 --- a/MediaBrowser.Model/Entities/MediaStream.cs +++ b/MediaBrowser.Model/Entities/MediaStream.cs @@ -260,6 +260,8 @@ namespace MediaBrowser.Model.Entities public string LocalizedHearingImpaired { get; set; } + public string LocalizedLanguage { get; set; } + public string DisplayTitle { get @@ -273,29 +275,8 @@ namespace MediaBrowser.Model.Entities // Do not display the language code in display titles if unset or set to a special code. Show it in all other cases (possibly expanded). if (!string.IsNullOrEmpty(Language) && !_specialCodes.Contains(Language, StringComparison.OrdinalIgnoreCase)) { - // Get full language string i.e. eng -> English, zh-Hans -> Chinese (Simplified). - var cultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures); - CultureInfo match = null; - if (Language.Contains('-', StringComparison.OrdinalIgnoreCase)) - { - match = cultures.FirstOrDefault(r => - r.Name.Equals(Language, StringComparison.OrdinalIgnoreCase)); - - if (match is null) - { - string baseLang = Language.AsSpan().LeftPart('-').ToString(); - match = cultures.FirstOrDefault(r => - r.TwoLetterISOLanguageName.Equals(baseLang, StringComparison.OrdinalIgnoreCase)); - } - } - else - { - match = cultures.FirstOrDefault(r => - r.ThreeLetterISOLanguageName.Equals(Language, StringComparison.OrdinalIgnoreCase)); - } - - string fullLanguage = match?.DisplayName; - attributes.Add(StringHelper.FirstToUpper(fullLanguage ?? Language)); + // Use pre-resolved localized language name, falling back to raw language code. + attributes.Add(StringHelper.FirstToUpper(LocalizedLanguage ?? Language)); } if (!string.IsNullOrEmpty(Profile) && !string.Equals(Profile, "lc", StringComparison.OrdinalIgnoreCase)) @@ -393,29 +374,8 @@ namespace MediaBrowser.Model.Entities if (!string.IsNullOrEmpty(Language)) { - // Get full language string i.e. eng -> English, zh-Hans -> Chinese (Simplified). - var cultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures); - CultureInfo match = null; - if (Language.Contains('-', StringComparison.OrdinalIgnoreCase)) - { - match = cultures.FirstOrDefault(r => - r.Name.Equals(Language, StringComparison.OrdinalIgnoreCase)); - - if (match is null) - { - string baseLang = Language.AsSpan().LeftPart('-').ToString(); - match = cultures.FirstOrDefault(r => - r.TwoLetterISOLanguageName.Equals(baseLang, StringComparison.OrdinalIgnoreCase)); - } - } - else - { - match = cultures.FirstOrDefault(r => - r.ThreeLetterISOLanguageName.Equals(Language, StringComparison.OrdinalIgnoreCase)); - } - - string fullLanguage = match?.DisplayName; - attributes.Add(StringHelper.FirstToUpper(fullLanguage ?? Language)); + // Use pre-resolved localized language name, falling back to raw language code. + attributes.Add(StringHelper.FirstToUpper(LocalizedLanguage ?? Language)); } else { diff --git a/MediaBrowser.Model/Extensions/StringHelper.cs b/MediaBrowser.Model/Extensions/StringHelper.cs index 77cbef00f..58cde8620 100644 --- a/MediaBrowser.Model/Extensions/StringHelper.cs +++ b/MediaBrowser.Model/Extensions/StringHelper.cs @@ -1,3 +1,5 @@ +using System; + namespace MediaBrowser.Model.Extensions { /// <summary> @@ -25,14 +27,11 @@ namespace MediaBrowser.Model.Extensions return string.Create( str.Length, - str, + str.AsSpan(), (chars, buf) => { chars[0] = char.ToUpperInvariant(buf[0]); - for (int i = 1; i < chars.Length; i++) - { - chars[i] = buf[i]; - } + buf.Slice(1).CopyTo(chars.Slice(1)); }); } } diff --git a/MediaBrowser.Model/MediaBrowser.Model.csproj b/MediaBrowser.Model/MediaBrowser.Model.csproj index ef025d02d..c655c4ccb 100644 --- a/MediaBrowser.Model/MediaBrowser.Model.csproj +++ b/MediaBrowser.Model/MediaBrowser.Model.csproj @@ -14,7 +14,7 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <PublishRepositoryUrl>true</PublishRepositoryUrl> @@ -37,13 +37,10 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" /> <PackageReference Include="MimeTypes"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="System.Globalization" /> - <PackageReference Include="System.Text.Json" /> </ItemGroup> <ItemGroup> diff --git a/MediaBrowser.Model/Net/IPData.cs b/MediaBrowser.Model/Net/IPData.cs index c116d883e..e016ffea1 100644 --- a/MediaBrowser.Model/Net/IPData.cs +++ b/MediaBrowser.Model/Net/IPData.cs @@ -1,6 +1,5 @@ using System.Net; using System.Net.Sockets; -using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork; namespace MediaBrowser.Model.Net; @@ -66,9 +65,9 @@ public class IPData { if (Address.Equals(IPAddress.None)) { - return Subnet.Prefix.AddressFamily.Equals(IPAddress.None) + return Subnet.BaseAddress.AddressFamily.Equals(IPAddress.None) ? AddressFamily.Unspecified - : Subnet.Prefix.AddressFamily; + : Subnet.BaseAddress.AddressFamily; } else { diff --git a/MediaBrowser.Providers/Manager/ItemImageProvider.cs b/MediaBrowser.Providers/Manager/ItemImageProvider.cs index 75882a088..e0354dbdf 100644 --- a/MediaBrowser.Providers/Manager/ItemImageProvider.cs +++ b/MediaBrowser.Providers/Manager/ItemImageProvider.cs @@ -88,7 +88,15 @@ namespace MediaBrowser.Providers.Manager } } - singular.AddRange(item.GetImages(ImageType.Backdrop)); + foreach (var backdrop in item.GetImages(ImageType.Backdrop)) + { + var imageInMetadataFolder = backdrop.Path.StartsWith(itemMetadataPath, StringComparison.OrdinalIgnoreCase); + if (imageInMetadataFolder || canDeleteLocal || item.IsSaveLocalMetadataEnabled()) + { + singular.Add(backdrop); + } + } + PruneImages(item, singular); return singular.Count > 0; @@ -466,10 +474,36 @@ namespace MediaBrowser.Providers.Manager } } - if (UpdateMultiImages(item, images, ImageType.Backdrop)) + bool hasBackdrop = false; + bool backdropStoredWithMedia = false; + + foreach (var image in images) { - changed = true; - foundImageTypes.Add(ImageType.Backdrop); + if (image.Type != ImageType.Backdrop) + { + continue; + } + + hasBackdrop = true; + + if (item.ContainingFolderPath is not null && item.ContainingFolderPath.Contains(Path.GetDirectoryName(image.FileInfo.FullName), StringComparison.OrdinalIgnoreCase)) + { + backdropStoredWithMedia = true; + break; + } + } + + if (hasBackdrop) + { + if (UpdateMultiImages(item, images, ImageType.Backdrop)) + { + changed = true; + } + + if (backdropStoredWithMedia) + { + foundImageTypes.Add(ImageType.Backdrop); + } } if (foundImageTypes.Count > 0) diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index a2102ca9c..e9cb46eab 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -153,7 +153,7 @@ namespace MediaBrowser.Providers.Manager if (isFirstRefresh) { - await SaveItemAsync(metadataResult, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); + await SaveItemAsync(metadataResult, ItemUpdateType.MetadataImport, false, cancellationToken).ConfigureAwait(false); } // Next run metadata providers @@ -247,7 +247,7 @@ namespace MediaBrowser.Providers.Manager } // Save to database - await SaveItemAsync(metadataResult, updateType, cancellationToken).ConfigureAwait(false); + await SaveItemAsync(metadataResult, updateType, isFirstRefresh, cancellationToken).ConfigureAwait(false); } return updateType; @@ -275,9 +275,14 @@ namespace MediaBrowser.Providers.Manager } } - protected async Task SaveItemAsync(MetadataResult<TItemType> result, ItemUpdateType reason, CancellationToken cancellationToken) + protected async Task SaveItemAsync(MetadataResult<TItemType> result, ItemUpdateType reason, bool reattachUserData, CancellationToken cancellationToken) { await result.Item.UpdateToRepositoryAsync(reason, cancellationToken).ConfigureAwait(false); + if (reattachUserData) + { + await result.Item.ReattachUserDataAsync(cancellationToken).ConfigureAwait(false); + } + if (result.Item.SupportsPeople && result.People is not null) { var baseItem = result.Item; diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index 43f0746ba..f8e2aece1 100644 --- a/MediaBrowser.Providers/Manager/ProviderManager.cs +++ b/MediaBrowser.Providers/Manager/ProviderManager.cs @@ -721,8 +721,6 @@ namespace MediaBrowser.Providers.Manager } } } - - _libraryManager.CreateItem(item, null); } /// <summary> diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj index 34b3104b0..ed0c63b97 100644 --- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj +++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj @@ -18,7 +18,6 @@ <PackageReference Include="AsyncKeyedLock" /> <PackageReference Include="LrcParser" /> <PackageReference Include="MetaBrainz.MusicBrainz" /> - <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" /> <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" /> <PackageReference Include="Microsoft.Extensions.Http" /> <PackageReference Include="Newtonsoft.Json" /> @@ -28,7 +27,7 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> diff --git a/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs b/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs index 8df15e440..e0a4c4f32 100644 --- a/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs +++ b/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs @@ -72,7 +72,7 @@ public class PlaylistMetadataService : MetadataService<Playlist, ItemLookupInfo> } else { - targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).Distinct().ToArray(); + targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).DistinctBy(i => i.Path).ToArray(); } if (replaceData || targetItem.Shares.Count == 0) diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs index c35324746..88c8e4f7c 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Jellyfin.Extensions; @@ -83,7 +84,9 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu if (!string.IsNullOrEmpty(releaseGroupId)) { var releaseGroupResult = await _musicBrainzQuery.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.Releases, null, cancellationToken).ConfigureAwait(false); - return GetReleaseGroupResult(releaseGroupResult.Releases); + + // No need to pass the cancellation token to GetReleaseGroupResultAsync as we're already passing it to ToBlockingEnumerable + return GetReleaseGroupResultAsync(releaseGroupResult.Releases, CancellationToken.None).ToBlockingEnumerable(cancellationToken); } var artistMusicBrainzId = searchInfo.GetMusicBrainzArtistId(); @@ -128,7 +131,7 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu } } - private IEnumerable<RemoteSearchResult> GetReleaseGroupResult(IEnumerable<IRelease>? releaseSearchResults) + private async IAsyncEnumerable<RemoteSearchResult> GetReleaseGroupResultAsync(IEnumerable<IRelease>? releaseSearchResults, [EnumeratorCancellation] CancellationToken cancellationToken = default) { if (releaseSearchResults is null) { @@ -138,7 +141,7 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu foreach (var result in releaseSearchResults) { // Fetch full release info, otherwise artists are missing - var fullResult = _musicBrainzQuery.LookupRelease(result.Id, Include.Artists | Include.ReleaseGroups); + var fullResult = await _musicBrainzQuery.LookupReleaseAsync(result.Id, Include.Artists | Include.ReleaseGroups, cancellationToken).ConfigureAwait(false); yield return GetReleaseResult(fullResult); } } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs index 414a0a3c9..2beb34e43 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs @@ -303,9 +303,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies CrewMember = crewMember, PersonType = TmdbUtils.MapCrewToPersonType(crewMember) }) - .Where(entry => - TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) || - TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase)); + .Where(entry => TmdbUtils.WantedCrewKinds.Contains(entry.PersonType)); if (config.HideMissingCrewMembers) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs index e30c555cb..f0e159f09 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs @@ -275,9 +275,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV CrewMember = crewMember, PersonType = TmdbUtils.MapCrewToPersonType(crewMember) }) - .Where(entry => - TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) || - TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase)); + .Where(entry => TmdbUtils.WantedCrewKinds.Contains(entry.PersonType)); if (config.HideMissingCrewMembers) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs index 1b429039e..0905a3bdc 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs @@ -120,9 +120,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV CrewMember = crewMember, PersonType = TmdbUtils.MapCrewToPersonType(crewMember) }) - .Where(entry => - TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) || - TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase)); + .Where(entry => TmdbUtils.WantedCrewKinds.Contains(entry.PersonType)); if (config.HideMissingCrewMembers) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs index f0828e826..82d4e5838 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs @@ -367,9 +367,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV CrewMember = crewMember, PersonType = TmdbUtils.MapCrewToPersonType(crewMember) }) - .Where(entry => - TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) || - TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase)); + .Where(entry => TmdbUtils.WantedCrewKinds.Contains(entry.PersonType)); if (config.HideMissingCrewMembers) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs index f5e59a278..d6e66a0e6 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs @@ -70,18 +70,19 @@ namespace MediaBrowser.Providers.Plugins.Tmdb public static PersonKind MapCrewToPersonType(Crew crew) { if (crew.Department.Equals("production", StringComparison.OrdinalIgnoreCase) - && crew.Job.Contains("director", StringComparison.OrdinalIgnoreCase)) + && crew.Job.Equals("director", StringComparison.OrdinalIgnoreCase)) { return PersonKind.Director; } if (crew.Department.Equals("production", StringComparison.OrdinalIgnoreCase) - && crew.Job.Contains("producer", StringComparison.OrdinalIgnoreCase)) + && crew.Job.Equals("producer", StringComparison.OrdinalIgnoreCase)) { return PersonKind.Producer; } - if (crew.Department.Equals("writing", StringComparison.OrdinalIgnoreCase)) + if (crew.Department.Equals("writing", StringComparison.OrdinalIgnoreCase) + && crew.Job.Equals("writer", StringComparison.OrdinalIgnoreCase)) { return PersonKind.Writer; } diff --git a/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj b/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj index b195af96c..cfb3533f3 100644 --- a/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj +++ b/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj @@ -15,7 +15,7 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> @@ -39,7 +39,7 @@ --- -Jellyfin is a Free Software Media System that puts you in control of managing and streaming your media. It is an alternative to the proprietary Emby and Plex, to provide media from a dedicated server to end-user devices via multiple apps. Jellyfin is descended from Emby's 3.5.2 release and ported to the .NET platform to enable full cross-platform support. +Jellyfin is a Free Software Media System that puts you in control of managing and streaming your media. It is an alternative to the proprietary Emby and Plex, to provide media from a dedicated server to end-user devices via multiple apps. Jellyfin is descended from Emby's 3.5.2 release and ported to the .NET platform to enable full cross-platform support. There are no strings attached, no premium licenses or features, and no hidden agendas: just a team that wants to build something better and work together to achieve it. We welcome anyone who is interested in joining us in our quest! @@ -133,7 +133,7 @@ A second option is to build the project and then run the resulting executable fi ```bash dotnet build # Build the project -cd Jellyfin.Server/bin/Debug/net9.0 # Change into the build output directory +cd Jellyfin.Server/bin/Debug/net10.0 # Change into the build output directory ``` 2. Execute the build output. On Linux, Mac, etc. use `./jellyfin` and on Windows use `jellyfin.exe`. diff --git a/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj b/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj index 1373d2fe0..1ac7402f9 100644 --- a/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj +++ b/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj @@ -2,7 +2,7 @@ <PropertyGroup> <OutputType>Exe</OutputType> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> </PropertyGroup> <ItemGroup> diff --git a/fuzz/Emby.Server.Implementations.Fuzz/fuzz.sh b/fuzz/Emby.Server.Implementations.Fuzz/fuzz.sh index 8183bb37a..771aa6677 100755 --- a/fuzz/Emby.Server.Implementations.Fuzz/fuzz.sh +++ b/fuzz/Emby.Server.Implementations.Fuzz/fuzz.sh @@ -8,4 +8,4 @@ cp bin/Emby.Server.Implementations.dll . dotnet build mkdir -p Findings -AFL_SKIP_BIN_CHECK=1 afl-fuzz -i "Testcases/$1" -o "Findings/$1" -t 5000 ./bin/Debug/net9.0/Emby.Server.Implementations.Fuzz "$1" +AFL_SKIP_BIN_CHECK=1 afl-fuzz -i "Testcases/$1" -o "Findings/$1" -t 5000 ./bin/Debug/net10.0/Emby.Server.Implementations.Fuzz "$1" diff --git a/fuzz/Jellyfin.Api.Fuzz/Jellyfin.Api.Fuzz.csproj b/fuzz/Jellyfin.Api.Fuzz/Jellyfin.Api.Fuzz.csproj index 04c7be11d..dad2f8e4e 100644 --- a/fuzz/Jellyfin.Api.Fuzz/Jellyfin.Api.Fuzz.csproj +++ b/fuzz/Jellyfin.Api.Fuzz/Jellyfin.Api.Fuzz.csproj @@ -2,7 +2,7 @@ <PropertyGroup> <OutputType>Exe</OutputType> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> </PropertyGroup> <ItemGroup> diff --git a/fuzz/Jellyfin.Api.Fuzz/fuzz.sh b/fuzz/Jellyfin.Api.Fuzz/fuzz.sh index 15148e1bb..537de905d 100755 --- a/fuzz/Jellyfin.Api.Fuzz/fuzz.sh +++ b/fuzz/Jellyfin.Api.Fuzz/fuzz.sh @@ -8,4 +8,4 @@ cp bin/Jellyfin.Api.dll . dotnet build mkdir -p Findings -AFL_SKIP_BIN_CHECK=1 afl-fuzz -i "Testcases/$1" -o "Findings/$1" -t 5000 ./bin/Debug/net9.0/Jellyfin.Api.Fuzz "$1" +AFL_SKIP_BIN_CHECK=1 afl-fuzz -i "Testcases/$1" -o "Findings/$1" -t 5000 ./bin/Debug/net10.0/Jellyfin.Api.Fuzz "$1" diff --git a/global.json b/global.json index 2e13a6387..867a4cfa0 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "9.0.0", + "version": "10.0.0", "rollForward": "latestMinor" } } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj index 28c4972d2..0b29a71cb 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs index 7bcc7eeca..76ffa5a9e 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs @@ -1,3 +1,5 @@ +#pragma warning disable CA1873 + using System; using System.Data.Common; using System.Linq; diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/PessimisticLockBehavior.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/PessimisticLockBehavior.cs index 2d6bc6902..404292e8e 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/PessimisticLockBehavior.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/PessimisticLockBehavior.cs @@ -1,5 +1,6 @@ #pragma warning disable MT1013 // Releasing lock without guarantee of execution #pragma warning disable MT1012 // Acquiring lock without guarantee of releasing +#pragma warning disable CA1873 using System; using System.Data; diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Jellyfin.Database.Providers.Sqlite.csproj b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Jellyfin.Database.Providers.Sqlite.csproj index 03e5fc495..aeee52701 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Jellyfin.Database.Providers.Sqlite.csproj +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Jellyfin.Database.Providers.Sqlite.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> diff --git a/src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj b/src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj index ba402dfe0..f7c20463f 100644 --- a/src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj +++ b/src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj @@ -6,7 +6,7 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <!-- TODO: Remove once we update SkiaSharp > 2.88.5 --> diff --git a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs index 503e2f941..c6eab92ea 100644 --- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs +++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs @@ -209,39 +209,69 @@ public class SkiaEncoder : IImageEncoder return default; } - using var codec = SKCodec.Create(safePath, out var result); - - switch (result) - { - case SKCodecResult.Success: - // Skia/SkiaSharp edge‑case: when the image header is parsed but the actual pixel - // decode fails (truncated JPEG/PNG, exotic ICC/EXIF, CMYK without color‑transform, etc.) - // `SKCodec.Create` returns a *non‑null* codec together with - // SKCodecResult.InternalError. The header still contains valid dimensions, - // which is all we need here – so we fall back to them instead of aborting. - // See e.g. Skia bugs #4139, #6092. - case SKCodecResult.InternalError when codec is not null: - var info = codec.Info; - return new ImageDimensions(info.Width, info.Height); - - case SKCodecResult.Unimplemented: - _logger.LogDebug("Image format not supported: {FilePath}", path); - return default; - - default: + SKCodec? codec = null; + bool isSafePathTemp = !string.Equals(Path.GetFullPath(safePath), Path.GetFullPath(path), StringComparison.OrdinalIgnoreCase); + try + { + codec = SKCodec.Create(safePath, out var result); + switch (result) { - var boundsInfo = SKBitmap.DecodeBounds(safePath); + case SKCodecResult.Success: + // Skia/SkiaSharp edge‑case: when the image header is parsed but the actual pixel + // decode fails (truncated JPEG/PNG, exotic ICC/EXIF, CMYK without color‑transform, etc.) + // `SKCodec.Create` returns a *non‑null* codec together with + // SKCodecResult.InternalError. The header still contains valid dimensions, + // which is all we need here – so we fall back to them instead of aborting. + // See e.g. Skia bugs #4139, #6092. + case SKCodecResult.InternalError when codec is not null: + var info = codec.Info; + return new ImageDimensions(info.Width, info.Height); + + case SKCodecResult.Unimplemented: + _logger.LogDebug("Image format not supported: {FilePath}", path); + return default; - if (boundsInfo.Width > 0 && boundsInfo.Height > 0) + default: { - return new ImageDimensions(boundsInfo.Width, boundsInfo.Height); + var boundsInfo = SKBitmap.DecodeBounds(safePath); + if (boundsInfo.Width > 0 && boundsInfo.Height > 0) + { + return new ImageDimensions(boundsInfo.Width, boundsInfo.Height); + } + + _logger.LogWarning( + "Unable to determine image dimensions for {FilePath}: {SkCodecResult}", + path, + result); + + return default; } + } + } + finally + { + try + { + codec?.Dispose(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Error by closing codec for {FilePath}", safePath); + } - _logger.LogWarning( - "Unable to determine image dimensions for {FilePath}: {SkCodecResult}", - path, - result); - return default; + if (isSafePathTemp) + { + try + { + if (File.Exists(safePath)) + { + File.Delete(safePath); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Unable to remove temporary file '{TempPath}'", safePath); + } } } } diff --git a/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj b/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj index 5f4b3fe8d..a442f7457 100644 --- a/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj +++ b/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj @@ -6,7 +6,7 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> diff --git a/src/Jellyfin.Extensions/AlphanumericComparator.cs b/src/Jellyfin.Extensions/AlphanumericComparator.cs deleted file mode 100644 index 299e2f94a..000000000 --- a/src/Jellyfin.Extensions/AlphanumericComparator.cs +++ /dev/null @@ -1,112 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace Jellyfin.Extensions -{ - /// <summary> - /// Alphanumeric <see cref="IComparer{T}" />. - /// </summary> - public class AlphanumericComparator : IComparer<string?> - { - /// <summary> - /// Compares two objects and returns a value indicating whether one is less than, equal to, or greater than the other. - /// </summary> - /// <param name="s1">The first object to compare.</param> - /// <param name="s2">The second object to compare.</param> - /// <returns>A signed integer that indicates the relative values of <c>x</c> and <c>y</c>.</returns> - public static int CompareValues(string? s1, string? s2) - { - if (s1 is null && s2 is null) - { - return 0; - } - - if (s1 is null) - { - return -1; - } - - if (s2 is null) - { - return 1; - } - - int len1 = s1.Length; - int len2 = s2.Length; - - // Early return for empty strings - if (len1 == 0 && len2 == 0) - { - return 0; - } - - if (len1 == 0) - { - return -1; - } - - if (len2 == 0) - { - return 1; - } - - int pos1 = 0; - int pos2 = 0; - - do - { - int start1 = pos1; - int start2 = pos2; - - bool isNum1 = char.IsDigit(s1[pos1++]); - bool isNum2 = char.IsDigit(s2[pos2++]); - - while (pos1 < len1 && char.IsDigit(s1[pos1]) == isNum1) - { - pos1++; - } - - while (pos2 < len2 && char.IsDigit(s2[pos2]) == isNum2) - { - pos2++; - } - - var span1 = s1.AsSpan(start1, pos1 - start1); - var span2 = s2.AsSpan(start2, pos2 - start2); - - if (isNum1 && isNum2) - { - // Trim leading zeros so we can compare the length - // of the strings to find the largest number - span1 = span1.TrimStart('0'); - span2 = span2.TrimStart('0'); - var span1Len = span1.Length; - var span2Len = span2.Length; - if (span1Len < span2Len) - { - return -1; - } - - if (span1Len > span2Len) - { - return 1; - } - } - - int result = span1.CompareTo(span2, StringComparison.InvariantCulture); - if (result != 0) - { - return result; - } - } while (pos1 < len1 && pos2 < len2); - - return len1 - len2; - } - - /// <inheritdoc /> - public int Compare(string? x, string? y) - { - return CompareValues(x, y); - } - } -} diff --git a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj index f52fd014d..9a7cf4aab 100644 --- a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj +++ b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <PublishRepositoryUrl>true</PublishRepositoryUrl> diff --git a/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj b/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj index f04c02504..575441de9 100644 --- a/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj +++ b/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj @@ -1,6 +1,6 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> @@ -13,7 +13,6 @@ <ItemGroup> <PackageReference Include="AsyncKeyedLock" /> <PackageReference Include="Jellyfin.XmlTv" /> - <PackageReference Include="System.Linq.Async" /> </ItemGroup> <ItemGroup> diff --git a/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj b/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj index 80b5aa84e..902f51376 100644 --- a/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj +++ b/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> @@ -12,9 +12,6 @@ <ProjectReference Include="../Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj" /> </ItemGroup> - <ItemGroup> - <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" /> - </ItemGroup> <ItemGroup> <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo"> diff --git a/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj b/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj index cc8d942eb..5e7e2090c 100644 --- a/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj +++ b/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> @@ -23,10 +23,6 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" /> - </ItemGroup> - - <ItemGroup> <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo"> <_Parameter1>Jellyfin.MediaEncoding.Keyframes.Tests</_Parameter1> </AssemblyAttribute> diff --git a/src/Jellyfin.Networking/Jellyfin.Networking.csproj b/src/Jellyfin.Networking/Jellyfin.Networking.csproj index 1a146549d..36b9581a7 100644 --- a/src/Jellyfin.Networking/Jellyfin.Networking.csproj +++ b/src/Jellyfin.Networking/Jellyfin.Networking.csproj @@ -1,6 +1,6 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> diff --git a/src/Jellyfin.Networking/Manager/NetworkManager.cs b/src/Jellyfin.Networking/Manager/NetworkManager.cs index 126d9f15c..a9136aad4 100644 --- a/src/Jellyfin.Networking/Manager/NetworkManager.cs +++ b/src/Jellyfin.Networking/Manager/NetworkManager.cs @@ -16,7 +16,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using static MediaBrowser.Controller.Extensions.ConfigurationExtensions; using IConfigurationManager = MediaBrowser.Common.Configuration.IConfigurationManager; -using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork; namespace Jellyfin.Networking.Manager; @@ -115,7 +114,7 @@ public class NetworkManager : INetworkManager, IDisposable public static string MockNetworkSettings { get; set; } = string.Empty; /// <summary> - /// Gets a value indicating whether IP4 is enabled. + /// Gets a value indicating whether IPv4 is enabled. /// </summary> public bool IsIPv4Enabled => _configurationManager.GetNetworkConfiguration().EnableIPv4; @@ -341,12 +340,12 @@ public class NetworkManager : INetworkManager, IDisposable } else { - _lanSubnets = lanSubnets; + _lanSubnets = lanSubnets.Select(x => x.Subnet).ToArray(); } _excludedSubnets = NetworkUtils.TryParseToSubnets(subnets, out var excludedSubnets, true) - ? excludedSubnets - : new List<IPNetwork>(); + ? excludedSubnets.Select(x => x.Subnet).ToArray() + : Array.Empty<IPNetwork>(); } } @@ -362,7 +361,7 @@ public class NetworkManager : INetworkManager, IDisposable } /// <summary> - /// Filteres a list of bind addresses and exclusions on available interfaces. + /// Filters a list of bind addresses and exclusions on available interfaces. /// </summary> /// <param name="config">The network config to be filtered by.</param> /// <param name="interfaces">A list of possible interfaces to be filtered.</param> @@ -376,7 +375,7 @@ public class NetworkManager : INetworkManager, IDisposable if (localNetworkAddresses.Length > 0 && !string.IsNullOrWhiteSpace(localNetworkAddresses[0])) { var bindAddresses = localNetworkAddresses.Select(p => NetworkUtils.TryParseToSubnet(p, out var network) - ? network.Prefix + ? network.Address : (interfaces.Where(x => x.Name.Equals(p, StringComparison.OrdinalIgnoreCase)) .Select(x => x.Address) .FirstOrDefault() ?? IPAddress.None)) @@ -445,7 +444,7 @@ public class NetworkManager : INetworkManager, IDisposable var remoteFilteredSubnets = remoteIPFilter.Where(x => x.Contains('/', StringComparison.OrdinalIgnoreCase)).ToArray(); if (NetworkUtils.TryParseToSubnets(remoteFilteredSubnets, out var remoteAddressFilterResult, false)) { - remoteAddressFilter = remoteAddressFilterResult.ToList(); + remoteAddressFilter = remoteAddressFilterResult.Select(x => x.Subnet).ToList(); } // Parse everything else as an IP and construct subnet with a single IP @@ -545,7 +544,7 @@ public class NetworkManager : INetworkManager, IDisposable { foreach (var lan in _lanSubnets) { - var lanPrefix = lan.Prefix; + var lanPrefix = lan.BaseAddress; publishedServerUrls.Add( new PublishedServerUriOverride( new IPData(lanPrefix, new IPNetwork(lanPrefix, lan.PrefixLength)), @@ -554,12 +553,11 @@ public class NetworkManager : INetworkManager, IDisposable false)); } } - else if (NetworkUtils.TryParseToSubnet(identifier, out var result) && result is not null) + else if (NetworkUtils.TryParseToSubnet(identifier, out var result)) { - var data = new IPData(result.Prefix, result); publishedServerUrls.Add( new PublishedServerUriOverride( - data, + result, replacement, true, true)); @@ -621,16 +619,12 @@ public class NetworkManager : INetworkManager, IDisposable foreach (var details in interfaceList) { var parts = details.Split(','); - if (NetworkUtils.TryParseToSubnet(parts[0], out var subnet)) + if (NetworkUtils.TryParseToSubnet(parts[0], out var data)) { - var address = subnet.Prefix; - var index = int.Parse(parts[1], CultureInfo.InvariantCulture); - if (address.AddressFamily == AddressFamily.InterNetwork || address.AddressFamily == AddressFamily.InterNetworkV6) + data.Index = int.Parse(parts[1], CultureInfo.InvariantCulture); + if (data.AddressFamily == AddressFamily.InterNetwork || data.AddressFamily == AddressFamily.InterNetworkV6) { - var data = new IPData(address, subnet, parts[2]) - { - Index = index - }; + data.Name = parts[2]; interfaces.Add(data); } } @@ -920,7 +914,7 @@ public class NetworkManager : INetworkManager, IDisposable { if (NetworkUtils.TryParseToSubnet(address, out var subnet)) { - return IsInLocalNetwork(subnet.Prefix); + return IsInLocalNetwork(subnet.Address); } return NetworkUtils.TryParseHost(address, out var addresses, IsIPv4Enabled, IsIPv6Enabled) @@ -1171,13 +1165,13 @@ public class NetworkManager : INetworkManager, IDisposable var logLevel = debug ? LogLevel.Debug : LogLevel.Information; if (_logger.IsEnabled(logLevel)) { - _logger.Log(logLevel, "Defined LAN subnets: {Subnets}", _lanSubnets.Select(s => s.Prefix + "/" + s.PrefixLength)); - _logger.Log(logLevel, "Defined LAN exclusions: {Subnets}", _excludedSubnets.Select(s => s.Prefix + "/" + s.PrefixLength)); - _logger.Log(logLevel, "Used LAN subnets: {Subnets}", _lanSubnets.Where(s => !_excludedSubnets.Contains(s)).Select(s => s.Prefix + "/" + s.PrefixLength)); + _logger.Log(logLevel, "Defined LAN subnets: {Subnets}", _lanSubnets.Select(s => s.BaseAddress + "/" + s.PrefixLength)); + _logger.Log(logLevel, "Defined LAN exclusions: {Subnets}", _excludedSubnets.Select(s => s.BaseAddress + "/" + s.PrefixLength)); + _logger.Log(logLevel, "Used LAN subnets: {Subnets}", _lanSubnets.Where(s => !_excludedSubnets.Contains(s)).Select(s => s.BaseAddress + "/" + s.PrefixLength)); _logger.Log(logLevel, "Filtered interface addresses: {Addresses}", _interfaces.OrderByDescending(x => x.AddressFamily == AddressFamily.InterNetwork).Select(x => x.Address)); _logger.Log(logLevel, "Bind Addresses {Addresses}", GetAllBindInterfaces(false).OrderByDescending(x => x.AddressFamily == AddressFamily.InterNetwork).Select(x => x.Address)); _logger.Log(logLevel, "Remote IP filter is {Type}", config.IsRemoteIPFilterBlacklist ? "Blocklist" : "Allowlist"); - _logger.Log(logLevel, "Filtered subnets: {Subnets}", _remoteAddressFilter.Select(s => s.Prefix + "/" + s.PrefixLength)); + _logger.Log(logLevel, "Filtered subnets: {Subnets}", _remoteAddressFilter.Select(s => s.BaseAddress + "/" + s.PrefixLength)); } } } diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index 6b851021f..feec35307 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -4,7 +4,7 @@ <Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" /> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> <IsPackable>false</IsPackable> </PropertyGroup> diff --git a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj index 015018910..6b84c4438 100644 --- a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj +++ b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj @@ -10,7 +10,6 @@ <PackageReference Include="AutoFixture.AutoMoq" /> <PackageReference Include="AutoFixture.Xunit2" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" /> - <PackageReference Include="Microsoft.Extensions.Options" /> <PackageReference Include="Microsoft.NET.Test.Sdk" /> <PackageReference Include="xunit" /> <PackageReference Include="xunit.runner.visualstudio"> diff --git a/tests/Jellyfin.Extensions.Tests/AlphanumericComparatorTests.cs b/tests/Jellyfin.Extensions.Tests/AlphanumericComparatorTests.cs deleted file mode 100644 index 105e2a52a..000000000 --- a/tests/Jellyfin.Extensions.Tests/AlphanumericComparatorTests.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using System.Linq; -using Xunit; - -namespace Jellyfin.Extensions.Tests -{ - public class AlphanumericComparatorTests - { - // InlineData is pre-sorted - [Theory] - [InlineData(null, "", "1", "9", "10", "a", "z")] - [InlineData("50F", "100F", "SR9", "SR100")] - [InlineData("image-1.jpg", "image-02.jpg", "image-4.jpg", "image-9.jpg", "image-10.jpg", "image-11.jpg", "image-22.jpg")] - [InlineData("Hard drive 2GB", "Hard drive 20GB")] - [InlineData("b", "e", "è", "ě", "f", "g", "k")] - [InlineData("123456789", "123456789a", "abc", "abcd")] - [InlineData("12345678912345678912345678913234567891", "123456789123456789123456789132345678912")] - [InlineData("12345678912345678912345678913234567891", "12345678912345678912345678913234567891")] - [InlineData("12345678912345678912345678913234567891", "12345678912345678912345678913234567892")] - [InlineData("12345678912345678912345678913234567891a", "12345678912345678912345678913234567891a")] - [InlineData("12345678912345678912345678913234567891a", "12345678912345678912345678913234567891b")] - [InlineData("a5", "a11")] - [InlineData("a05a", "a5b")] - [InlineData("a5a", "a05b")] - [InlineData("6xxx", "007asdf")] - [InlineData("00042Q", "42s")] - public void AlphanumericComparatorTest(params string?[] strings) - { - var copy = strings.Reverse().ToArray(); - Array.Sort(copy, new AlphanumericComparator()); - Assert.Equal(strings, copy); - } - } -} diff --git a/tests/Jellyfin.LiveTv.Tests/Jellyfin.LiveTv.Tests.csproj b/tests/Jellyfin.LiveTv.Tests/Jellyfin.LiveTv.Tests.csproj index fdcf7d61e..bdf6bc383 100644 --- a/tests/Jellyfin.LiveTv.Tests/Jellyfin.LiveTv.Tests.csproj +++ b/tests/Jellyfin.LiveTv.Tests/Jellyfin.LiveTv.Tests.csproj @@ -1,6 +1,6 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>net9.0</TargetFramework> + <TargetFramework>net10.0</TargetFramework> </PropertyGroup> <ItemGroup> diff --git a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs index 94710a095..8a2f84734 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs @@ -196,6 +196,18 @@ namespace Jellyfin.MediaEncoding.Tests.Probing } [Fact] + public void GetMediaInfo_WebM_Like_Mkv() + { + var bytes = File.ReadAllBytes("Test Data/Probing/video_web_like_mkv_with_subtitle.json"); + var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions); + + MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_metadata.mkv", MediaProtocol.File); + + Assert.Equal("mkv", res.Container); + Assert.Equal(3, res.MediaStreams.Count); + } + + [Fact] public void GetMediaInfo_ProgressiveVideoNoFieldOrder_Success() { var bytes = File.ReadAllBytes("Test Data/Probing/video_progressive_no_field_order.json"); diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_web_like_mkv_with_subtitle.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_web_like_mkv_with_subtitle.json new file mode 100644 index 000000000..4f52dd90d --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_web_like_mkv_with_subtitle.json @@ -0,0 +1,137 @@ +{ + "streams": [ + { + "index": 0, + "codec_name": "vp8", + "codec_long_name": "On2 VP8", + "profile": "1", + "codec_type": "video", + "codec_tag_string": "[0][0][0][0]", + "codec_tag": "0x0000", + "width": 540, + "height": 360, + "coded_width": 540, + "coded_height": 360, + "closed_captions": 0, + "film_grain": 0, + "has_b_frames": 0, + "sample_aspect_ratio": "1:1", + "display_aspect_ratio": "3:2", + "pix_fmt": "yuv420p", + "level": -99, + "field_order": "progressive", + "refs": 1, + "r_frame_rate": "2997/125", + "avg_frame_rate": "2997/125", + "time_base": "1/1000", + "start_pts": 0, + "start_time": "0.000000", + "disposition": { + "default": 1, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0, + "captions": 0, + "descriptions": 0, + "metadata": 0, + "dependent": 0, + "still_image": 0 + }, + "tags": { + "language": "eng" + } + }, + { + "index": 1, + "codec_name": "vorbis", + "codec_long_name": "Vorbis", + "codec_type": "audio", + "codec_tag_string": "[0][0][0][0]", + "codec_tag": "0x0000", + "sample_fmt": "fltp", + "sample_rate": "44100", + "channels": 2, + "channel_layout": "stereo", + "bits_per_sample": 0, + "r_frame_rate": "0/0", + "avg_frame_rate": "0/0", + "time_base": "1/1000", + "start_pts": 0, + "start_time": "0.000000", + "duration": "117.707000", + "bit_rate": "127998", + "disposition": { + "default": 1, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0, + "captions": 0, + "descriptions": 0, + "metadata": 0, + "dependent": 0, + "still_image": 0 + }, + "tags": { + "language": "eng" + } + }, + { + "index": 2, + "codec_name": "subrip", + "codec_long_name": "SubRip subtitle", + "codec_type": "subtitle", + "codec_tag_string": "[0][0][0][0]", + "codec_tag": "0x0000", + "disposition": { + "default": 0, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0, + "captions": 0, + "descriptions": 0, + "metadata": 0, + "dependent": 0, + "still_image": 0 + }, + "tags": { + "language": "eng" + } + } + ], + "format": { + "filename": "sample.mkv", + "nb_streams": 3, + "nb_programs": 0, + "format_name": "matroska,webm", + "format_long_name": "Matroska / WebM", + "start_time": "0.000000", + "duration": "117.700914", + "size": "8566268", + "bit_rate": "582239", + "probe_score": 100 + } +} diff --git a/tests/Jellyfin.Model.Tests/Dlna/LegacyStreamInfo.cs b/tests/Jellyfin.Model.Tests/Dlna/LegacyStreamInfo.cs index e32baef55..6436d7d0e 100644 --- a/tests/Jellyfin.Model.Tests/Dlna/LegacyStreamInfo.cs +++ b/tests/Jellyfin.Model.Tests/Dlna/LegacyStreamInfo.cs @@ -134,8 +134,6 @@ public class LegacyStreamInfo : StreamInfo { list.Add(new NameValuePair("MinSegments", item.MinSegments.Value.ToString(CultureInfo.InvariantCulture))); } - - list.Add(new NameValuePair("BreakOnNonKeyFrames", item.BreakOnNonKeyFrames.ToString(CultureInfo.InvariantCulture))); } else { diff --git a/tests/Jellyfin.Model.Tests/Entities/MediaStreamTests.cs b/tests/Jellyfin.Model.Tests/Entities/MediaStreamTests.cs index f4c0d9fe8..c1a3a4544 100644 --- a/tests/Jellyfin.Model.Tests/Entities/MediaStreamTests.cs +++ b/tests/Jellyfin.Model.Tests/Entities/MediaStreamTests.cs @@ -108,6 +108,49 @@ namespace Jellyfin.Model.Tests.Entities IsExternal = true }); + // Test LocalizedLanguage is used when set (fixes zh-CN display issue #15935) + data.Add( + "Chinese (Simplified) - SRT", + new MediaStream + { + Type = MediaStreamType.Subtitle, + Title = null, + Language = "zh-CN", + LocalizedLanguage = "Chinese (Simplified)", + IsForced = false, + IsDefault = false, + Codec = "SRT" + }); + + // Test LocalizedLanguage for audio streams + data.Add( + "Japanese - AAC - Stereo", + new MediaStream + { + Type = MediaStreamType.Audio, + Title = null, + Language = "jpn", + LocalizedLanguage = "Japanese", + IsForced = false, + IsDefault = false, + Codec = "AAC", + ChannelLayout = "stereo" + }); + + // Test fallback to Language when LocalizedLanguage is null + data.Add( + "Eng - ASS", + new MediaStream + { + Type = MediaStreamType.Subtitle, + Title = null, + Language = "eng", + LocalizedLanguage = null, + IsForced = false, + IsDefault = false, + Codec = "ASS" + }); + return data; } diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-AndroidPixel.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-AndroidPixel.json index 68ce3ea4a..643ff2638 100644 --- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-AndroidPixel.json +++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-AndroidPixel.json @@ -152,7 +152,6 @@ "EnableSubtitlesInManifest": false, "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -169,7 +168,6 @@ "EnableSubtitlesInManifest": false, "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -185,7 +183,6 @@ "EnableSubtitlesInManifest": false, "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" } ], diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-AndroidTVExoPlayer.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-AndroidTVExoPlayer.json index 3d3968268..44f63f384 100644 --- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-AndroidTVExoPlayer.json +++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-AndroidTVExoPlayer.json @@ -130,7 +130,6 @@ "EnableSubtitlesInManifest": false, "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -146,7 +145,6 @@ "EnableSubtitlesInManifest": false, "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" } ], diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Chrome-NoHLS.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Chrome-NoHLS.json index 5d1f5f162..f1fc9e0db 100644 --- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Chrome-NoHLS.json +++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Chrome-NoHLS.json @@ -127,7 +127,6 @@ "MaxAudioChannels": "6", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -144,7 +143,6 @@ "MaxAudioChannels": "6", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -161,7 +159,6 @@ "MaxAudioChannels": "6", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -178,7 +175,6 @@ "MaxAudioChannels": "6", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -195,7 +191,6 @@ "MaxAudioChannels": "6", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -212,7 +207,6 @@ "MaxAudioChannels": "6", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -229,7 +223,6 @@ "MaxAudioChannels": "6", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -246,7 +239,6 @@ "MaxAudioChannels": "6", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -263,7 +255,6 @@ "EnableSubtitlesInManifest": false, "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -281,7 +272,6 @@ "MaxAudioChannels": "6", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -298,7 +288,6 @@ "EnableSubtitlesInManifest": false, "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" } ], diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Chrome.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Chrome.json index e2f75b569..7e37a6236 100644 --- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Chrome.json +++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Chrome.json @@ -107,7 +107,6 @@ "Protocol": "hls", "MaxAudioChannels": "2", "MinSegments": "2", - "BreakOnNonKeyFrames": true, "EnableAudioVbrEncoding": true }, { @@ -182,8 +181,7 @@ "Context": "Streaming", "Protocol": "hls", "MaxAudioChannels": "2", - "MinSegments": "2", - "BreakOnNonKeyFrames": true + "MinSegments": "2" }, { "Container": "ts", @@ -193,8 +191,7 @@ "Context": "Streaming", "Protocol": "hls", "MaxAudioChannels": "2", - "MinSegments": "2", - "BreakOnNonKeyFrames": true + "MinSegments": "2" } ], "ContainerProfiles": [], diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Firefox.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Firefox.json index 21ae7e5cb..4380d80ef 100644 --- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Firefox.json +++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Firefox.json @@ -95,7 +95,6 @@ "TranscodingProfiles": [ { "AudioCodec": "aac", - "BreakOnNonKeyFrames": true, "Container": "mp4", "Context": "Streaming", "EnableAudioVbrEncoding": true, @@ -170,7 +169,6 @@ }, { "AudioCodec": "aac,mp2,opus,flac", - "BreakOnNonKeyFrames": true, "Container": "mp4", "Context": "Streaming", "MaxAudioChannels": "2", @@ -181,7 +179,6 @@ }, { "AudioCodec": "aac,mp3,mp2", - "BreakOnNonKeyFrames": true, "Container": "ts", "Context": "Streaming", "MaxAudioChannels": "2", diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-JellyfinMediaPlayer.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-JellyfinMediaPlayer.json index da9a1a4ad..cca1c16ee 100644 --- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-JellyfinMediaPlayer.json +++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-JellyfinMediaPlayer.json @@ -30,7 +30,6 @@ "EnableSubtitlesInManifest": false, "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -48,7 +47,6 @@ "MaxAudioChannels": "6", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -62,7 +60,6 @@ "EnableSubtitlesInManifest": false, "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" } ], diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-LowBandwidth.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-LowBandwidth.json index 82b73fb0f..b7cd170b9 100644 --- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-LowBandwidth.json +++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-LowBandwidth.json @@ -30,7 +30,6 @@ "EnableSubtitlesInManifest": false, "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -48,7 +47,6 @@ "MaxAudioChannels": "6", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -62,7 +60,6 @@ "EnableSubtitlesInManifest": false, "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" } ], diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-RokuSSPlus.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-RokuSSPlus.json index 37b923558..b823ac4b8 100644 --- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-RokuSSPlus.json +++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-RokuSSPlus.json @@ -49,7 +49,6 @@ "MaxAudioChannels": " 2", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -66,7 +65,6 @@ "MaxAudioChannels": "2", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -83,7 +81,6 @@ "MaxAudioChannels": "2", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -100,7 +97,6 @@ "MaxAudioChannels": " 2", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -118,7 +114,6 @@ "MaxAudioChannels": " 2", "MinSegments": 1, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -135,7 +130,6 @@ "EnableSubtitlesInManifest": false, "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" } ], diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-RokuSSPlusNext.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-RokuSSPlusNext.json index 542bf6370..708ff73c4 100644 --- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-RokuSSPlusNext.json +++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-RokuSSPlusNext.json @@ -49,7 +49,6 @@ "MaxAudioChannels": " 2", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -66,7 +65,6 @@ "MaxAudioChannels": "2", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -83,7 +81,6 @@ "MaxAudioChannels": "2", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -100,7 +97,6 @@ "MaxAudioChannels": " 2", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -118,7 +114,6 @@ "MaxAudioChannels": " 2", "MinSegments": 1, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -135,7 +130,6 @@ "EnableSubtitlesInManifest": false, "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" } ], diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-SafariNext.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-SafariNext.json index f61d0e36b..10382fa82 100644 --- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-SafariNext.json +++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-SafariNext.json @@ -114,7 +114,6 @@ "Protocol": "hls", "MaxAudioChannels": "6", "MinSegments": "2", - "BreakOnNonKeyFrames": true, "EnableAudioVbrEncoding": true }, { @@ -173,8 +172,7 @@ "Context": "Streaming", "Protocol": "hls", "MaxAudioChannels": "2", - "MinSegments": "2", - "BreakOnNonKeyFrames": true + "MinSegments": "2" }, { "Container": "ts", @@ -184,8 +182,7 @@ "Context": "Streaming", "Protocol": "hls", "MaxAudioChannels": "2", - "MinSegments": "2", - "BreakOnNonKeyFrames": true + "MinSegments": "2" } ], "ContainerProfiles": [], diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen3-stereo.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen3-stereo.json index 9d43d2166..3625b099c 100644 --- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen3-stereo.json +++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen3-stereo.json @@ -165,7 +165,6 @@ "MaxAudioChannels": "2", "MinSegments": 1, "SegmentLength": 0, - "BreakOnNonKeyFrames": true, "$type": "TranscodingProfile" }, { @@ -182,7 +181,6 @@ "MaxAudioChannels": "2", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -199,7 +197,6 @@ "MaxAudioChannels": "2", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -216,7 +213,6 @@ "MaxAudioChannels": "2", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -233,7 +229,6 @@ "MaxAudioChannels": "2", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -250,7 +245,6 @@ "MaxAudioChannels": "2", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -267,7 +261,6 @@ "MaxAudioChannels": "2", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -284,7 +277,6 @@ "MaxAudioChannels": "2", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -301,7 +293,6 @@ "MaxAudioChannels": "2", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -319,7 +310,6 @@ "MaxAudioChannels": "2", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "Conditions": [ { "Condition": "LessThanEqual", @@ -346,7 +336,6 @@ "MaxAudioChannels": "2", "MinSegments": 1, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "Conditions": [ { "Condition": "LessThanEqual", @@ -373,7 +362,6 @@ "MaxAudioChannels": "2", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "Conditions": [ { "Condition": "LessThanEqual", @@ -399,7 +387,6 @@ "EnableSubtitlesInManifest": false, "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "Conditions": [ { "Condition": "LessThanEqual", diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen4-4K-5.1.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen4-4K-5.1.json index 3859ef994..deee650b2 100644 --- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen4-4K-5.1.json +++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen4-4K-5.1.json @@ -165,7 +165,6 @@ "MaxAudioChannels": "6", "MinSegments": 1, "SegmentLength": 0, - "BreakOnNonKeyFrames": true, "$type": "TranscodingProfile" }, { @@ -182,7 +181,6 @@ "MaxAudioChannels": "6", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -199,7 +197,6 @@ "MaxAudioChannels": "6", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -216,7 +213,6 @@ "MaxAudioChannels": "6", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -233,7 +229,6 @@ "MaxAudioChannels": "6", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -250,7 +245,6 @@ "MaxAudioChannels": "6", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -267,7 +261,6 @@ "MaxAudioChannels": "6", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -284,7 +277,6 @@ "MaxAudioChannels": "6", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -301,7 +293,6 @@ "MaxAudioChannels": "6", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -319,7 +310,6 @@ "MaxAudioChannels": "6", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "Conditions": [ { "Condition": "LessThanEqual", @@ -346,7 +336,6 @@ "MaxAudioChannels": "6", "MinSegments": 1, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "Conditions": [ { "Condition": "LessThanEqual", @@ -373,7 +362,6 @@ "MaxAudioChannels": "6", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "Conditions": [ { "Condition": "LessThanEqual", @@ -399,7 +387,6 @@ "EnableSubtitlesInManifest": false, "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "Conditions": [ { "Condition": "LessThanEqual", diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-TranscodeMedia.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-TranscodeMedia.json index 9fc1ae6bb..38de51b04 100644 --- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-TranscodeMedia.json +++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-TranscodeMedia.json @@ -16,7 +16,6 @@ "EnableSubtitlesInManifest": false, "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -28,7 +27,6 @@ "Protocol": "hls", "MaxAudioChannels": "2", "MinSegments": "2", - "BreakOnNonKeyFrames": true, "$type": "TranscodingProfile" }, { @@ -40,7 +38,6 @@ "Protocol": "hls", "MaxAudioChannels": "2", "MinSegments": "2", - "BreakOnNonKeyFrames": true, "$type": "TranscodingProfile" }, { @@ -64,7 +61,6 @@ "EnableSubtitlesInManifest": false, "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" } ], diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-WebOS-23.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-WebOS-23.json index 094b0723b..3ff11a684 100644 --- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-WebOS-23.json +++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-WebOS-23.json @@ -135,7 +135,6 @@ "Protocol": "hls", "MaxAudioChannels": "6", "MinSegments": "1", - "BreakOnNonKeyFrames": false, "EnableAudioVbrEncoding": true }, { @@ -210,8 +209,7 @@ "Context": "Streaming", "Protocol": "hls", "MaxAudioChannels": "6", - "MinSegments": "1", - "BreakOnNonKeyFrames": false + "MinSegments": "1" } ], "ContainerProfiles": [], diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Yatse.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Yatse.json index 256c8dc2f..838a1f920 100644 --- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Yatse.json +++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Yatse.json @@ -52,7 +52,6 @@ "MaxAudioChannels": "6", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -70,7 +69,6 @@ "MaxAudioChannels": "6", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -88,7 +86,6 @@ "MaxAudioChannels": "6", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" } ], diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Yatse2.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Yatse2.json index 256c8dc2f..838a1f920 100644 --- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Yatse2.json +++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Yatse2.json @@ -52,7 +52,6 @@ "MaxAudioChannels": "6", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -70,7 +69,6 @@ "MaxAudioChannels": "6", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" }, { @@ -88,7 +86,6 @@ "MaxAudioChannels": "6", "MinSegments": 0, "SegmentLength": 0, - "BreakOnNonKeyFrames": false, "$type": "TranscodingProfile" } ], diff --git a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs index 38208476f..871604514 100644 --- a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs +++ b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs @@ -113,7 +113,7 @@ namespace Jellyfin.Networking.Tests public void IPv4SubnetMaskMatchesValidIPAddress(string netMask, string ipAddress) { var ipa = IPAddress.Parse(ipAddress); - Assert.True(NetworkUtils.TryParseToSubnet(netMask, out var subnet) && subnet.Contains(IPAddress.Parse(ipAddress))); + Assert.True(NetworkUtils.TryParseToSubnet(netMask, out var subnet) && subnet.Subnet.Contains(IPAddress.Parse(ipAddress))); } /// <summary> @@ -131,7 +131,7 @@ namespace Jellyfin.Networking.Tests public void IPv4SubnetMaskDoesNotMatchInvalidIPAddress(string netMask, string ipAddress) { var ipa = IPAddress.Parse(ipAddress); - Assert.False(NetworkUtils.TryParseToSubnet(netMask, out var subnet) && subnet.Contains(IPAddress.Parse(ipAddress))); + Assert.False(NetworkUtils.TryParseToSubnet(netMask, out var subnet) && subnet.Subnet.Contains(IPAddress.Parse(ipAddress))); } /// <summary> @@ -147,7 +147,7 @@ namespace Jellyfin.Networking.Tests [InlineData("2001:db8:abcd:0012::0/128", "2001:0DB8:ABCD:0012:0000:0000:0000:0000")] public void IPv6SubnetMaskMatchesValidIPAddress(string netMask, string ipAddress) { - Assert.True(NetworkUtils.TryParseToSubnet(netMask, out var subnet) && subnet.Contains(IPAddress.Parse(ipAddress))); + Assert.True(NetworkUtils.TryParseToSubnet(netMask, out var subnet) && subnet.Subnet.Contains(IPAddress.Parse(ipAddress))); } [Theory] @@ -158,7 +158,7 @@ namespace Jellyfin.Networking.Tests [InlineData("2001:db8:abcd:0012::0/128", "2001:0DB8:ABCD:0012:0000:0000:0000:0001")] public void IPv6SubnetMaskDoesNotMatchInvalidIPAddress(string netMask, string ipAddress) { - Assert.False(NetworkUtils.TryParseToSubnet(netMask, out var subnet) && subnet.Contains(IPAddress.Parse(ipAddress))); + Assert.False(NetworkUtils.TryParseToSubnet(netMask, out var subnet) && subnet.Subnet.Contains(IPAddress.Parse(ipAddress))); } [Theory] diff --git a/tests/Jellyfin.Server.Implementations.Tests/Cryptography/CryptographyProviderTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Cryptography/CryptographyProviderTests.cs new file mode 100644 index 000000000..052bdf740 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Cryptography/CryptographyProviderTests.cs @@ -0,0 +1,102 @@ +using System; +using Emby.Server.Implementations.Cryptography; +using MediaBrowser.Model.Cryptography; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Cryptography; + +public class CryptographyProviderTests +{ + private readonly CryptographyProvider _sut = new(); + + [Fact] + public void CreatePasswordHash_WithPassword_ReturnsHashWithIterations() + { + var hash = _sut.CreatePasswordHash("testpassword"); + + Assert.Equal("PBKDF2-SHA512", hash.Id); + Assert.True(hash.Parameters.ContainsKey("iterations")); + Assert.NotEmpty(hash.Salt.ToArray()); + Assert.NotEmpty(hash.Hash.ToArray()); + } + + [Fact] + public void Verify_WithValidPassword_ReturnsTrue() + { + const string password = "testpassword"; + var hash = _sut.CreatePasswordHash(password); + + Assert.True(_sut.Verify(hash, password)); + } + + [Fact] + public void Verify_WithWrongPassword_ReturnsFalse() + { + var hash = _sut.CreatePasswordHash("correctpassword"); + + Assert.False(_sut.Verify(hash, "wrongpassword")); + } + + [Fact] + public void Verify_PBKDF2_MissingIterations_ThrowsFormatException() + { + var hash = PasswordHash.Parse("$PBKDF2$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D"); + + var exception = Assert.Throws<FormatException>(() => _sut.Verify(hash, "password")); + Assert.Contains("missing required 'iterations' parameter", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void Verify_PBKDF2SHA512_MissingIterations_ThrowsFormatException() + { + var hash = PasswordHash.Parse("$PBKDF2-SHA512$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D"); + + var exception = Assert.Throws<FormatException>(() => _sut.Verify(hash, "password")); + Assert.Contains("missing required 'iterations' parameter", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void Verify_PBKDF2_InvalidIterationsFormat_ThrowsFormatException() + { + var hash = PasswordHash.Parse("$PBKDF2$iterations=abc$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D"); + + var exception = Assert.Throws<FormatException>(() => _sut.Verify(hash, "password")); + Assert.Contains("invalid 'iterations' parameter", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void Verify_PBKDF2SHA512_InvalidIterationsFormat_ThrowsFormatException() + { + var hash = PasswordHash.Parse("$PBKDF2-SHA512$iterations=notanumber$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D"); + + var exception = Assert.Throws<FormatException>(() => _sut.Verify(hash, "password")); + Assert.Contains("invalid 'iterations' parameter", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void Verify_UnsupportedHashId_ThrowsNotSupportedException() + { + var hash = PasswordHash.Parse("$UNKNOWN$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D"); + + Assert.Throws<NotSupportedException>(() => _sut.Verify(hash, "password")); + } + + [Fact] + public void GenerateSalt_ReturnsNonEmptyArray() + { + var salt = _sut.GenerateSalt(); + + Assert.NotEmpty(salt); + } + + [Theory] + [InlineData(16)] + [InlineData(32)] + [InlineData(64)] + public void GenerateSalt_WithLength_ReturnsArrayOfSpecifiedLength(int length) + { + var salt = _sut.GenerateSalt(length); + + Assert.Equal(length, salt.Length); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryTests.cs new file mode 100644 index 000000000..c450cbb0e --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryTests.cs @@ -0,0 +1,72 @@ +using System; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +public class BaseItemRepositoryTests +{ + [Fact] + public void DeserializeBaseItem_WithUnknownType_ReturnsNull() + { + // Arrange + var entity = new BaseItemEntity + { + Id = Guid.NewGuid(), + Type = "NonExistent.Plugin.CustomItemType" + }; + + // Act + var result = BaseItemRepository.DeserializeBaseItem(entity, NullLogger.Instance, null, false); + + // Assert + Assert.Null(result); + } + + [Fact] + public void DeserializeBaseItem_WithUnknownType_LogsWarning() + { + // Arrange + var entity = new BaseItemEntity + { + Id = Guid.NewGuid(), + Type = "NonExistent.Plugin.CustomItemType" + }; + var loggerMock = new Mock<ILogger>(); + + // Act + BaseItemRepository.DeserializeBaseItem(entity, loggerMock.Object, null, false); + + // Assert + loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny<EventId>(), + It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("unknown type", StringComparison.OrdinalIgnoreCase)), + It.IsAny<Exception?>(), + It.IsAny<Func<It.IsAnyType, Exception?, string>>()), + Times.Once); + } + + [Fact] + public void DeserializeBaseItem_WithKnownType_ReturnsItem() + { + // Arrange + var entity = new BaseItemEntity + { + Id = Guid.NewGuid(), + Type = "MediaBrowser.Controller.Entities.Movies.Movie" + }; + + // Act + var result = BaseItemRepository.DeserializeBaseItem(entity, NullLogger.Instance, null, false); + + // Assert + Assert.NotNull(result); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/DotIgnoreIgnoreRuleTest.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/DotIgnoreIgnoreRuleTest.cs index d677c9f09..a7bbef7ed 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/DotIgnoreIgnoreRuleTest.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/DotIgnoreIgnoreRuleTest.cs @@ -1,30 +1,81 @@ +using Emby.Server.Implementations.Library; using Xunit; namespace Jellyfin.Server.Implementations.Tests.Library; public class DotIgnoreIgnoreRuleTest { - [Fact] - public void Test() + private static readonly string[] _rule1 = ["SPs"]; + private static readonly string[] _rule2 = ["SPs", "!thebestshot.mkv"]; + private static readonly string[] _rule3 = ["*.txt", @"{\colortbl;\red255\green255\blue255;}", "videos/", @"\invalid\escape\sequence", "*.mkv"]; + private static readonly string[] _rule4 = [@"{\colortbl;\red255\green255\blue255;}", @"\invalid\escape\sequence"]; + + public static TheoryData<string[], string, bool, bool> CheckIgnoreRulesTestData => + new() + { + // Basic pattern matching + { _rule1, "f:/cd/sps/ffffff.mkv", false, true }, + { _rule1, "cd/sps/ffffff.mkv", false, true }, + { _rule1, "/cd/sps/ffffff.mkv", false, true }, + + // Negate pattern + { _rule2, "f:/cd/sps/ffffff.mkv", false, true }, + { _rule2, "cd/sps/ffffff.mkv", false, true }, + { _rule2, "/cd/sps/ffffff.mkv", false, true }, + { _rule2, "f:/cd/sps/thebestshot.mkv", false, false }, + { _rule2, "cd/sps/thebestshot.mkv", false, false }, + { _rule2, "/cd/sps/thebestshot.mkv", false, false }, + + // Mixed valid and invalid patterns - skips invalid, applies valid + { _rule3, "test.txt", false, true }, + { _rule3, "videos/movie.mp4", false, true }, + { _rule3, "movie.mkv", false, true }, + { _rule3, "test.mp3", false, false }, + + // Only invalid patterns - falls back to ignore all + { _rule4, "any-file.txt", false, true }, + { _rule4, "any/path/to/file.mkv", false, true }, + }; + + public static TheoryData<string[], string, bool, bool> WindowsPathNormalizationTestData => + new() + { + // Windows paths with backslashes - should match when normalizePath is true + { _rule1, @"C:\cd\sps\ffffff.mkv", false, true }, + { _rule1, @"D:\media\sps\movie.mkv", false, true }, + { _rule1, @"\\server\share\sps\file.mkv", false, true }, + + // Negate pattern with Windows paths + { _rule2, @"C:\cd\sps\ffffff.mkv", false, true }, + { _rule2, @"C:\cd\sps\thebestshot.mkv", false, false }, + + // Directory matching with Windows paths + { _rule3, @"C:\videos\movie.mp4", false, true }, + { _rule3, @"D:\documents\test.txt", false, true }, + { _rule3, @"E:\music\song.mp3", false, false }, + }; + + [Theory] + [MemberData(nameof(CheckIgnoreRulesTestData))] + public void CheckIgnoreRules_ReturnsExpectedResult(string[] rules, string path, bool isDirectory, bool expectedIgnored) + { + Assert.Equal(expectedIgnored, DotIgnoreIgnoreRule.CheckIgnoreRules(path, rules, isDirectory)); + } + + [Theory] + [MemberData(nameof(WindowsPathNormalizationTestData))] + public void CheckIgnoreRules_WithWindowsPaths_NormalizesBackslashes(string[] rules, string path, bool isDirectory, bool expectedIgnored) { - var ignore = new Ignore.Ignore(); - ignore.Add("SPs"); - Assert.True(ignore.IsIgnored("f:/cd/sps/ffffff.mkv")); - Assert.True(ignore.IsIgnored("cd/sps/ffffff.mkv")); - Assert.True(ignore.IsIgnored("/cd/sps/ffffff.mkv")); + // With normalizePath=true, backslashes should be converted to forward slashes + Assert.Equal(expectedIgnored, DotIgnoreIgnoreRule.CheckIgnoreRules(path, rules, isDirectory, normalizePath: true)); } - [Fact] - public void TestNegatePattern() + [Theory] + [InlineData(@"C:\cd\sps\ffffff.mkv")] + [InlineData(@"D:\media\sps\movie.mkv")] + public void CheckIgnoreRules_WithWindowsPaths_WithoutNormalization_DoesNotMatch(string path) { - var ignore = new Ignore.Ignore(); - ignore.Add("SPs"); - ignore.Add("!thebestshot.mkv"); - Assert.True(ignore.IsIgnored("f:/cd/sps/ffffff.mkv")); - Assert.True(ignore.IsIgnored("cd/sps/ffffff.mkv")); - Assert.True(ignore.IsIgnored("/cd/sps/ffffff.mkv")); - Assert.True(!ignore.IsIgnored("f:/cd/sps/thebestshot.mkv")); - Assert.True(!ignore.IsIgnored("cd/sps/thebestshot.mkv")); - Assert.True(!ignore.IsIgnored("/cd/sps/thebestshot.mkv")); + // Without normalization, Windows paths with backslashes won't match patterns expecting forward slashes + Assert.False(DotIgnoreIgnoreRule.CheckIgnoreRules(path, _rule1, isDirectory: false, normalizePath: false)); } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/IgnorePatternsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/IgnorePatternsTests.cs index 07061cfc7..4cb6cb960 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/IgnorePatternsTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/IgnorePatternsTests.cs @@ -19,7 +19,7 @@ namespace Jellyfin.Server.Implementations.Tests.Library [InlineData("/media/movies/#recycle", true)] [InlineData("thumbs.db", true)] [InlineData(@"C:\media\movies\movie.avi", false)] - [InlineData("/media/.hiddendir/file.mp4", false)] + [InlineData("/media/.hiddendir/file.mp4", true)] [InlineData("/media/dir/.hiddenfile.mp4", true)] [InlineData("/media/dir/._macjunk.mp4", true)] [InlineData("/volume1/video/Series/@eaDir", true)] @@ -32,7 +32,7 @@ namespace Jellyfin.Server.Implementations.Tests.Library [InlineData("/media/music/Foo B.A.R", false)] [InlineData("/media/music/Foo B.A.R.", false)] [InlineData("/movies/.zfs/snapshot/AutoM-2023-09", true)] - public void PathIgnored(string path, bool expected) + public void PathIgnored(string path, bool expected) { Assert.Equal(expected, IgnorePatterns.ShouldIgnore(path)); } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs index 6d6bba4fc..e60522bf7 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs @@ -204,6 +204,25 @@ namespace Jellyfin.Server.Implementations.Tests.Localization } [Theory] + [InlineData("TV-MA", "DE", 17, 1)] // US-only rating, DE country code + [InlineData("PG-13", "FR", 13, 0)] // US-only rating, FR country code + [InlineData("R", "JP", 17, 0)] // US-only rating, JP country code + public async Task GetRatingScore_FallbackPrioritizesUS_Success(string rating, string countryCode, int expectedScore, int? expectedSubScore) + { + var localizationManager = Setup(new ServerConfiguration() + { + MetadataCountryCode = countryCode + }); + await localizationManager.LoadAll(); + + var score = localizationManager.GetRatingScore(rating); + + Assert.NotNull(score); + Assert.Equal(expectedScore, score.Score); + Assert.Equal(expectedSubScore, score.SubScore); + } + + [Theory] [InlineData("Default", "Default")] [InlineData("HeaderLiveTV", "Live TV")] public void GetLocalizedString_Valid_Success(string key, string expected) diff --git a/tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj b/tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj index 8228c0df7..7b0e23788 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj +++ b/tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj @@ -5,7 +5,6 @@ <PackageReference Include="AutoFixture.AutoMoq" /> <PackageReference Include="AutoFixture.Xunit2" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" /> - <PackageReference Include="Microsoft.Extensions.Options" /> <PackageReference Include="Microsoft.NET.Test.Sdk" /> <PackageReference Include="xunit" /> <PackageReference Include="xunit.runner.visualstudio"> diff --git a/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj b/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj index 5fea805ae..21596e0ed 100644 --- a/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj +++ b/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj @@ -5,7 +5,6 @@ <PackageReference Include="AutoFixture.AutoMoq" /> <PackageReference Include="AutoFixture.Xunit2" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" /> - <PackageReference Include="Microsoft.Extensions.Options" /> <PackageReference Include="Microsoft.NET.Test.Sdk" /> <PackageReference Include="xunit" /> <PackageReference Include="xunit.runner.visualstudio"> diff --git a/tests/Jellyfin.Server.Tests/ParseNetworkTests.cs b/tests/Jellyfin.Server.Tests/ParseNetworkTests.cs index 123266d29..14f4c33b6 100644 --- a/tests/Jellyfin.Server.Tests/ParseNetworkTests.cs +++ b/tests/Jellyfin.Server.Tests/ParseNetworkTests.cs @@ -11,7 +11,6 @@ using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; using IConfigurationManager = MediaBrowser.Common.Configuration.IConfigurationManager; -using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork; namespace Jellyfin.Server.Tests { @@ -87,7 +86,7 @@ namespace Jellyfin.Server.Tests // Need this here as ::1 and 127.0.0.1 are in them by default. options.KnownProxies.Clear(); - options.KnownNetworks.Clear(); + options.KnownIPNetworks.Clear(); ApiServiceCollectionExtensions.AddProxyAddresses(settings, hostList, options); @@ -97,10 +96,10 @@ namespace Jellyfin.Server.Tests Assert.True(options.KnownProxies.Contains(item)); } - Assert.Equal(knownNetworks.Length, options.KnownNetworks.Count); + Assert.Equal(knownNetworks.Length, options.KnownIPNetworks.Count); foreach (var item in knownNetworks) { - Assert.NotNull(options.KnownNetworks.FirstOrDefault(x => x.Prefix.Equals(item.Prefix) && x.PrefixLength == item.PrefixLength)); + Assert.NotEqual(default, options.KnownIPNetworks.FirstOrDefault(x => x.BaseAddress.Equals(item.BaseAddress) && x.PrefixLength == item.PrefixLength)); } } |
